ПИТЕР
Chris Richardson
11
MANNING
Shelter Island
Крис Ричардсон
/^77/ПЕР’ Санкт-Петербург • Москва • Екатеринбург • Воронеж
Нижний Новгород * Ростов-на-Дону • Самара•Минск
2019
ББК 32.988.02-018
УДК 004.738.5
Р56
Ричардсон Крис
Р56 Микросервисы. Паттерны разработки и рефакторинга. — СПб.: Питер, 2019. —
544 с.: ил. — (Серия «Библиотека программиста»).
ISBN 978-5-4461-0996-8
Если вам давно кажется, что вся разработка и развертывание в вашей компании донельзя замед
лились — переходите на микросервисную архитектуру. Она обеспечивает непрерывную разработку,
доставку и развертывание приложений любой сложности.
Книга, предназначенная для разработчиков и архитекторов из больших корпораций, рассказывает,
как проекгировагь и писать приложения в духе микросервисной архитектуры. Также в ней описано,
как делается рефакторинг крупного приложения — и монолит превращается в набор микросервисов.
16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.)
ББК 32.988.02-018
УДК 004.738.5
Права на издание получены по соглашению с Apress Все права защищены Никакая часть данной книги не может
быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав
Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как на
дежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может
гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные
ошибки, связанные с использованием книги. Издательство не несет ответственности за доступность материалов,
ссылки на которые вы можете найти в этой книге На момент подготовки книги к изданию все ссылки на интернет-
ресурсы были действующими.
ISBN 978-1617294549 англ.
ISBN 978-5-4461-0996-8
© 2019 by Chris Richardson All rights reserved
© Перевод на русский язык ООО Издательство «Питер», 2019
© Издание на русском языке, оформление ООО Издательство «Питер», 2019
©Серия «Библиотека программиста», 2019
Краткое содержание
Предисловие...................................................................................................................................16
Благодарности............................................................................................................................... 19
О книге............................................................................................................................................21
Глава 1. Побег из монолитного ада......................................................................................... 26
Глава 2. Стратегии декомпозиции............................................................................................63
Глава 3. Межпроцессное взаимодействие в микросервисной архитектуре...................... 97
Глава 4. Управление транзакциями с помощью повествований.......................................146
Глава 5. Проектирование бизнес-логики в микросервисной архитектуре.......................185
Глава 6. Разработка бизнес-логики с порождением событий........................................... 223
Глава 7. Реализация запросов в микросервисной архитектуре........................................264
Глава 8. Шаблоны внешних API.............................................................................................. 301
Глава 9. Тестирование микросервисов, часть 1.................................................................. 343
Глава 10. Тестирование микросервисов, часть 2...............................................................373
Глава 11. Разработка сервисов, готовых к промышленному использованию............... 405
Глава 12. Развертывание микросервисов.............................................................................446
Глава 13. Процесс перехода на микросервисы................................................................... 495
Оглавление
Предисловие.................................................................................................................................. 16
Благодарности...............................................................................................................................19
О книге........................................................................................................................................... 21
Кому следует прочитать эту книгу........................................................................................21
Структура издания................................................................................................................... 21
О коде........................................................................................................................................ 23
Онлайн-ресурсы.......................................................................................................................23
Об авторе.................................................................................................................................. 23
Об иллюстрации на обложке.................................................................................................24
От издательства.......................................................................................................................25
Глава 1. Побег из монолитного ада.........................................................................................26
1.1. Медленным шагом в монолитный ад..........................................................................27
1.1.1. Архитектура приложения FTGO...................................................................... 28
1.1.2. Преимущества монолитной архитектуры...................................................... 29
1.1.3. Жизнь в монолитном аду.................................................................................29
1.2. Почему эта книга актуальна для вас..........................................................................32
1.3. Чему вы научитесь, прочитав эту книгу..................................................................... 33
1.4. Микросервисная архитектура спешит на помощь.................................................... 34
1.4.1. Куб масштабирования и микросервисы.........................................................34
1.4.2. Микросервисы как разновидность модульности..........................................37
1.4.3. У каждого сервиса есть своя база данных.................................................... 38
1.4.4. Микросервисная архитектура для FTGO....................................................... 38
1.4.5. Сравнение микросервисной и сервис-ориентированной архитектур.......40
1.5. Достоинства и недостатки микросервисной архитектуры....................................... 41
1.5.1. Достоинства микросервисной архитектуры..................................................41
1.5.2. Недостатки микросервисной архитектуры....................................................44
1.6. Язык шаблонов микросервисной архитектуры..........................................................46
1.6.1. Микросервисная архитектура не панацея....................................................47
1.6.2. Шаблоны проектирования и языки шаблонов............................................. 48
1.6.3. Обзор языка шаблонов микросервисной архитектуры............................... 51
1.7. Помимо микросервисов: процесс и организация.......................................................58
1.7.1. Организация разработки и доставки программного обеспечения............59
1.7.2. Процесс разработки и доставки программного обеспечения....................60
1.7.3. Человеческий фактор при переходе на микросервисы................................61
Резюме.......................................................................................................................................62
Глава 2. Стратегии декомпозиции........................................................................................... 63
2.1. Что представляет собой микросервисная архитектура............................................. 64
2.1.1. Что такое архитектура программного обеспечения
и почему она важна.......................................................................................... 64
2.1.2. Обзор архитектурных стилей...........................................................................67
2.1.3. Микросервисная архитектура как архитектурный стиль............................70
2.2. Определение микросервисной архитектуры приложения........................................74
2.2.1. Определение системных операций.................................................................76
2.2.2. Разбиение на сервисы по бизнес-возможностям........................................ 82
2.2.3. Разбиение на сервисы по проблемным областям....................................... 85
2.2.4. Методические рекомендации по декомпозиции...........................................87
2.2.5. Трудности при разбиении приложения на сервисы....................................88
2.2.6. Определение API сервисов............................................................................. 92
Резюме.......................................................................................................................................95
Глава 3. Межпроцессное взаимодействие в микросервисной архитектуре......................97
3.1. Обзор межпроцессного взаимодействия в микросервисной архитектуре.......... 98
3.1.1. Стили взаимодействия..................................................................................... 99
3.1.2. Описание API в микросервисной архитектуре........................................... 100
3.1.3. Развивающиеся API........................................................................................ 101
3.1.4. Форматы сообщений.......................................................................................103
3.2. Взаимодействие на основе удаленного вызова процедур.................................... 105
3.2.1. Использование REST.......................................................................................106
3.2.2. Использование gRPC.......................................................................................109
3.2.3. Работа в условиях частичного отказа с применением шаблона
«Предохранитель».......................................................................................... 111
3.2.4. Обнаружение сервисов............................................................................. 114
3.3. Взаимодействие с помощью асинхронного обмена сообщениями........................119
3.3.1. Обзор механизмов обмена сообщениями................................................... 119
3.3.2. Реализация стилей взаимодействия с помощью сообщений...................122
3.3.3. Создание спецификации для API сервиса на основе сообщений.......... 124
3.3.4. Использование брокера сообщений............................................................ 125
3.3.5. Конкурирующие получатели и порядок следования сообщений.......... 129
3.3.6. Дублирование сообщений..............................................................................130
3.3.7. Транзакционный обмен сообщениями.........................................................132
3.3.8. Библиотеки и фреймворки для обмена сообщениями.............................. 136
3.4. Использование асинхронного обмена сообщениями
для улучшения доступности.......................................................................................139
3.4.1. Синхронное взаимодействие снижает степень доступности.................... 139
3.4.2. Избавление от синхронного взаимодействия............................................ 141
Резюме..................................................................................................................................... 144
Глава 4. Управление транзакциями с помощью повествований.......................................146
4.1. Управление транзакциями в микросервисной архитектуре..................................147
4.1.1. Микросервисная архитектура и необходимость в распределенных
транзакциях......................................................................................................148
4.1.2. Проблемы с распределенными транзакциями........................................... 148
4.1.3. Использование шаблона «Повествование» для сохранения
согласованности данных................................................................................ 150
4.2. Координация повествований......................................................................................154
4.2.1. Повествования, основанные на хореографии............................................ 154
4.2.2. Повествования на основе оркестрации.......................................................159
4.3. Что делать с недостаточной изолированностью.....................................................164
4.3.1. Обзор аномалий............................................................................................... 165
4.3.2. Контрмеры на случай нехватки изолированности.................................... 166
4.4. Архитектура сервиса Order и повествования Create Order................................... 170
4.4.1. Класс OrderService...........................................................................................172
4.4.2. Реализация повествования Create Order.....................................................173
4.4.3. Класс OrderCommandHandlers....................................................................... 181
4.4.4. Класс OrderServiceConfiguration.....................................................................182
Резюме..................................................................................................................................... 184
Глава 5. Проектирование бизнес-логики в микросервисной архитектуре.......................185
5.1. Шаблоны организации бизнес-логики......................................................................186
5.1.1. Проектирование бизнес-логики с помощью
шаблона «Сценарий транзакции»................................................................ 188
5.1.2. Проектирование бизнес-логики с помощью
шаблона «Доменная модель»....................................................................... 189
5.1.3. О предметно-ориентированном проектировании.......................................190
5.2. Проектирование доменной модели с помощью шаблона «Агрегат» из DDD.... 191
5.2.1. Проблемы с расплывчатыми границами......................................................192
5.2.2. Агрегаты имеют четкие границы...................................................................194
5.2.3. Правила для агрегатов...................................................................................195
5.2.4. Размеры агрегатов...........................................................................................198
5.2.5. Проектирование бизнес-логики с помощью агрегатов............................. 199
5.3. Публикация доменных событий................................................................................. 200
5.3.1. Зачем публиковать события об изменениях...............................................200
5.3.2. Что такое доменное событие........................................................................ 201
5.3.3. Обогащение события......................................................................................202
5.3.4. Определение доменных событий................................................................. 202
5.3.5. Генерация и публикация доменных событий............................................. 204
5.3.6. Потребление доменных событий.................................................................. 207
5.4. Бизнес-логика сервиса Kitchen.................................................................................. 208
5.4.1. Агрегат Ticket...................................................................................................210
5.5. Бизнес-логика сервиса Order......................................................................................214
5.5.1. Агрегат Order....................................................................................................215
5.5.2. Класс OrderService.......................................................................................... 220
Резюме.....................................................................................................................................222
Глава 6. Разработка бизнес-логики с порождением событий...........................................223
6.1. Разработка бизнес-логики с использованием порождения событий...................224
6.1.1. Проблемы традиционного сохранения данных.......................................... 225
6.1.2. Обзор порождения событий..........................................................................227
6.1.3. Обработка конкурентных обновлений с помощью оптимистичного
блокирования..................................................................................................234
6.1.4. Порождение и публикация событий............................................................ 235
6.1.5. Улучшение производительности с помощью снимков.............................. 236
6.1.6. Идемпотентная обработка сообщений....................................................... 238
6.1.7. Развитие доменных событий.........................................................................239
6.1.8. Преимущества порождения событий...........................................................241
6.1.9. Недостатки порождения событий.................................................................242
6.2. Реализация хранилища событий................................................................................244
6.2.1. Принцип работы хранилища событий Eventuate Local..............................245
6.2.2. Клиентский фреймворк Eventuate для Java............................................... 248
6.3. Совместное использование повествований и порождения событий.................... 252
6.3.1. Реализация повествований на основе хореографии с помощью
порождения событий..................................................................................... 253
6.3.2. Создание повествования на основе оркестрации..................................... 254
6.3.3. Реализация участника повествования на основе порождения
событий............................................................................................................ 256
6.3.4. Реализация оркестраторов повествований с помощью
порождения событий..................................................................................... 260
Резюме.....................................................................................................................................262
Глава 7. Реализация запросов в микросервисной архитектуре....................................... 264
7.1. Выполнение запросов с помощью объединения API............................................. 265
7.1.1. Запрос findOrder()........................................................................................... 265
7.1.2. Обзор шаблона «Объединение API»............................................................266
7.1.3. Реализация запроса flndOrder() путем объединения API......................... 268
7.1.4. Архитектурные проблемы объединения API.............................................. 269
7.1.5. Преимущества и недостатки объединения API..........................................272
7.2. Применение шаблона CQRS........................................................................................273
7.2.1. Потенциальные причины использования CQRS........................................ 274
7.2.2. Обзор CQRS......................................................................................................277
7.2.3. Преимущества CQRS.......................................................................................280
7.2.4. Недостатки CQRS............................................................................................ 281
7.3. Проектирование CQRS-представлений.................................................................... 282
7.3.1. Выбор хранилища данных для представления..........................................283
7.3.2. Структура модуля доступа к данным........................................................... 285
7.3.3. Добавление и обновление CQRS-представлений...................................... 288
7.4. Реализация CQRS с использованием AWS DynamoDB............................................ 289
7.4.1. Модуль OrderHistoryEventHandlers................................................................290
7.4.2. Моделирование данных и проектирование запросов
с помощью DynamoDB.................... 291
7.4.3. Класс OrderHistoryDaoDynamoDb.................................................................. 296
Резюме.....................................................................................................................................299
Глава 8. Шаблоны внешних API..............................................................................................301
8.1. Проблемы с проектированием внешних API............................................................ 302
8.1.1. Проблемы проектирования API для мобильного клиента FTGO............. 303
8.1.2. Проблемы с проектированием API для клиентов другого рода.............306
8.2. Шаблон «API-шлюз»....................................................................................................307
8.2.1. Обзор шаблона «API-шлюз».......................................................................... 308
8.2.2. Преимущества и недостатки API-шлюза..................................................... 315
8.2.3. Netflix как пример использования API-шлюза............................................ 316
8.2.4. Трудности проектирования API-шлюза........................................................316
8.3. Реализация API-шлюза................................................................................................ 320
8.3.1. Использование готового API-шлюза............................................................ 320
8.3.2. Разработка собственного API-шлюза........................................................... 322
8.3.3. Реализация API-шлюза с помощью GraphQL...............................................329
Резюме.....................................................................................................................................341
Глава 9. Тестирование микросервисов, часть 1.................................................................. 343
9.1. Стратегии тестирования микросервисных архитектур........................................... 345
9.1.1. Обзор методик тестирования........................................................................345
9.1.2. Трудности тестирования микросервисов.................................................... 352
9.1.3. Процесс развертывания................................................................................. 358
9.2. Написание модульных тестов для сервиса...............................................................360
9.2.1. Разработка модульных тестов для доменных сущностей.........................363
9.2.2. Написание модульных тестов для объектов значений............................. 364
9.2.3. Разработка модульных тестов для повествований.................................... 364
9.2.4. Написание модульных тестов для доменных сервисов............................366
9.2.5. Разработка модульных тестов для контроллеров..................................... 368
9.2.6. Написание модульных тестов для обработчиков событий
и сообщений.....................................................................................................370
Резюме.....................................................................................................................................371
Глава 10. Тестирование микросервисов, часть 2................................................................373
10.1. Написание интеграционных тестов........................................................................... 374
10.1.1. Интеграционные тесты с сохранением........................................................376
10.1.2. Интеграционное тестирование взаимодействия
в стиле «запрос/ответ» на основе REST....................................................378
10.1.3. Интеграционное тестирование взаимодействия
в стиле «издатель/подписчик»...................................................................382
10.1.4. Интеграционные тесты контрактов для взаимодействия на основе
асинхронных запросов/ответов.................................................................... 386
10.2. Разработка компонентных тестов..............................................................................391
10.2.1. Определение приемочных тестов.................................................................392
10.2.2. Написание приемочных тестов с помощью Gherkin..................................392
10.2.3. Проектирование компонентных тестов........................................................395
10.2.4. Написание компонентных тестов для сервиса Order.................................396
10.3. Написание сквозных тестов........................................................................................401
10.3.1. Проектирование сквозных тестов................................................................402
10.3.2. Написание сквозных тестов..........................................................................402
10.3.3. Выполнение сквозных тестов........................................................................403
Резюме.....................................................................................................................................403
Глава 11. Разработка сервисов, готовых к промышленному использованию................ 405
11.1. Разработка безопасных сервисов............................................................................. 406
11.1.1. Обзор безопасности в традиционном монолитном приложении.......... 407
11.1.2. Обеспечение безопасности в микросервисной архитектуре...................411
11.2. Проектирование конфигурируемых сервисов.........................................................420
11.2.1. Вынесение конфигурации вовне с помощью пассивной модели.......... 421
11.2.2. Вынесение конфигурации вовне с помощью активной модели.............423
11.3. Проектирование наблюдаемых сервисов.................................................................424
11.3.1. Использование API проверки работоспособности..................................... 426
11.3.2. Применение шаблона агрегации журналов............................................... 428
11.3.3. Использование шаблона распределенной трассировки...........................430
11.3.4. Применение шаблона «Показатели приложения»....................................434
11.3.5. Шаблон отслеживания исключений............................................................ 437
11.3.6. Применение шаблона «Ведение журнала аудита»...................................439
11.4. Разработка сервисов с помощью шаблона микросервисного шасси...................440
11.4.1. Использование шасси микросервисов.........................................................441
11.4.2. От микросервисного шасси до сети сервисов............................................ 442
Резюме.....................................................................................................................................444
Глава 12. Развертывание микросервисов.............................................................................446
12.1. Развертывание сервисов с помощью пакетов для отдельных языков................ 449
12.1.1. Преимущества использования пакетов для конкретных языков.........452
12.1.2. Недостатки применения пакетов для конкретных языков....................... 452
12.2. Развертывание сервисов в виде виртуальных машин...........................................454
12.2.1. Преимущества развертывания сервисов в виде ВМ..................................456
12.2.2. Недостатки развертывания сервисов в виде ВМ........................................457
12.3. Развертывание сервисов в виде контейнеров......................................................... 458
12.3.1. Развертывание сервисов с помощью Docker.............................................. 460
12.3.2. Преимущества развертывания сервисов в виде контейнеров................ 463
12.3.3. Недостатки развертывания сервисов в виде контейнеров..................... 463
12.4. Развертывание приложения FTGO с помощью Kubernetes................................... 463
12.4.1. Обзор Kubernetes.............................................................................................464
12.4.2. Развертывание сервиса Restaurant в Kubernetes........................................467
12.4.3. Развертывание API-шлюза............................................................................ 470
12.4.4. Развертывание без простоя.......................................................................... 471
12.4.5. Использование сети сервисов для отделения развертывания
от выпуска........................................................................................................ 472
12.5. Бессерверное развертывание сервисов................................................................... 481
12.5.1. Обзор бессерверного развертывания с помощью AWS Lambda.............. 482
12.5.2. Написание лямбда-функции.......................................................................... 483
12.5.3. Вызов лямбда-функций.................................................................................. 484
12.5.4. Преимущества использования лямбда-функций....................................... 485
12.5.5. Недостатки использования лямбда-функций............................................. 485
12.6. Развертывание RESTful-сервиса с помощью AWS Lambda и AWS Gateway.......486
12.6.1. Архитектура сервиса Restaurant на основе AWS Lambda........................... 487
12.6.2. Упаковывание сервиса в виде ZIP-файла....................................................491
12.6.3. Развертывание лямбда-функций с помощью бессерверного
фреймворка......................................................................................................492
Резюме.....................................................................................................................................493
Глава 13. Процесс перехода на микросервисы...................................................................495
13.1. Переход на микросервисы..........................................................................................496
13.1.1. Зачем переходить с монолита на что-то другое....................................... 496
13.1.2. «Удушение» монолита...................................................................................497
13.2. Стратегии перехода с монолита на микросервисы.................................................501
13.2.1. Реализация новых возможностей в виде сервисов...................................501
13.2.2. Разделение уровня представления и внутренних компонентов........... 503
13.2.3. Извлечение бизнес-возможносгей в сервисы............................................505
13.3. Проектирование взаимодействия между сервисом и монолитом........................ 512
13.3.1. Проектирование интеграционного слоя..................................................... 513
13.3.2. Обеспечение согласованности данных между сервисом
и монолитом.................................................................................................... 518
13.3.3. Аутентификация и авторизация...................................................................523
13.4. Реализация новой возможности в виде сервиса....................................................525
13.4.1. Архитектура сервиса Delayed Delivery.........................................................526
13.4.2. Проектирование интеграционного слоя для сервиса Delayed Order.....528
13.5. Разбиение монолита на части: извлечение управления доставкой....................530
13.5.1. Обзор возможностей существующего механизма управления
доставкой......................................................................................................... 530
13.5.2. Обзор сервиса Delivery................................................................................... 532
13.5.3. Проектирование доменной модели сервиса Delivery.................................533
13.5.4. Структура интеграционного слоя для сервиса Delivery............................536
13.5.5. Изменение монолита для взаимодействия с сервисом Delivery.............. 538
Резюме.....................................................................................................................................541
Столкнувшись с неправдой, неравенством или
несправедливостью, не молчите, ведь это ваша
страна. Это ваша демократия. Будьте ее творцом,
защитником и носителем.
Тэргуд Маршалл (Thurgood Marshall), судья
Верховного суда США
Предисловие
Будущее уже здесь — оно просто не очень
равномерно распределено.
Уильям Гибсон (William Gibson),
писатель-фантаст
Это одна из моих любимых цитат. Ее суть в том, что новые идеи и технологии ста
новятся общепринятыми и распространяются в обществе далеко не сразу. Хорошим
примером этого может служить мой опыт знакомства с микросервисами. Все нача
лось в 2006 году, когда, вдохновившись выступлением AWS-евангелиста, я вступил
на путь, который в итоге привел меня к созданию первой версии Cloud Foundry
(хотя с текущей версией ее роднит лишь название). Она представляла собой PaaS
(Platform-as-a-Service — платформа как услуга) для автоматизации развертывания
Java-приложений в ЕС2. Как и все другие мои промышленные приложения на Java,
Cloud Foundry имело монолитную архитектуру, состоящую из единого файла фор
мата WAR (Java Web Application Archive).
Объединение в монолит разнообразных сложных функций, таких как выделе
ние ресурсов, конфигурирование, мониторинг и управление, привело к проблемам,
связанным как с разработкой системы, так и с ее использованием. Например, вы
не могли изменить пользовательский интерфейс без тестирования и повторного
развертывания всего приложения. А поскольку компонент для мониторинга и управ
ления зависел от системы обработки сложных событий, хранившей свое состояние
в памяти, мы не могли запустить больше одного экземпляра Cloud Foundry! В этом
стыдно признаться, но я лишь разработчик программного обеспечения, и никто из
нас не без греха.
Очевидно, что приложение быстро переросло свою монолитную архитектуру, но
какие у нас были альтернативы? В то время подходящее решение уже было известно
разработчикам и применялось в таких компаниях, как eBay и Amazon. Например,
в Amazon миграция с монолита началась примерно в 2002 году. Новая архитектура
состояла из набора слабо связанных сервисов. За каждый сервис отвечала команда
Предисловие 17
«на две пиццы» (в терминологии Amazon) — достаточно компактная для того, чтобы
всех ее членов можно было накормить двумя пиццами.
Компания Amazon приняла на вооружение эту архитектуру, чтобы ускорить
темпы разработки, активизировать инновации и повысить конкурентоспособность.
Результаты оказались впечатляющими: как сообщается, изменения на промышлен
ном уровне в Amazon происходят каждые 11,6 с!
В начале 2010 года, после того как я переключился на другие проекты, будущее
программного проектирования наконец стало актуальным и для меня. Именно
тогда я прочитал книгу Майкла Т. Фишера (Michael Т. Fisher) и Мартина Л. Эббо
та (Martin L. Abbott) The Art of Scalability: Scalable Web Architecture, Processes and
Organizations forthe Modem Enterprise (AddisonWesley Professional, 2009). Ее ключе
вой идеей было трехмерное представление модели масштабирования приложения
в виде куба. В соответствии с ним масштабирование по оси Y обозначает разбиение
приложения на сервисы. Сейчас такой подход кажется довольно очевидным, но в то
время у меня как будто открылись глаза. Наконец-то я мог решить проблемы двух
годичной давности, спроектировав Cloud Foundry в виде набора сервисов!
В апреле 2012 года я впервые описал этот метод проектирования в своем докла
де под названием «Декомпозиция приложений для улучшения развертываемости
и масштабируемости» (www.slideshare.net/chris.e.richardson/decomposing-applications-for-
scalability-and-deployability-april-2012). На тот момент у подобной архитектуры не было
общепринятого названия. Иногда я называл ее модульной, многоязычной, поскольку
сервисы могут быть написаны на разных языках.
Еще одним примером неравномерного распределения будущего может служить
тот факт, что термин «микросервис» (ru.wikipedia.org/wiki/МикросервиснаЯ-архитектура)
использовался на семинаре по архитектуре программного обеспечения еще в 2011 году,
хотя я впервые услышал его во время выступления Фреда Джорджа (Fred George)
на конференции Oredev 2013 и мне он пришелся по душе!
В январе 2014 года я создал сайт microservices.io, чтобы описать архитектуру и ша
блоны проектирования, с которыми столкнулся. А в марте 2014-го Джеймс Льюис
(James Lewis) и Мартин Фаулер (Martin Fowler) опубликовали статью о микро
сервисах (martinfowler.com/articles/microservices.html), которая популяризировала этот
термин и сплотила сообщество вокруг новой концепции.
Идея небольших слабо связанных между собой команд, которые быстро и на
дежно разрабатывают и доставляют микросервисы, постепенно набирает популяр
ность в сообществе программистов. Важные промышленные бизнес-приложения
сегодня обычно являются монолитными и разрабатываются крупными командами.
Выпуск новых версий происходит редко и, как правило, создает проблемы для всех,
кто вовлечен в этот процесс. Информационные технологии часто не поспевают за
потребностями бизнеса. Как же перейти на микросервисную архитектуру, учитывая
сказанное?
Ответом на этот вопрос должна послужить данная книга. Прочитав ее, вы начнете
хорошо понимать архитектуру микросервисов, узнаете о преимуществах и недо
статках этого подхода, а также научитесь использовать его в подходящих ситуациях.
В книге описываются способы решения бесчисленных проблем проектирования,
18 Предисловие
с которыми вы будете сталкиваться, включая работу с распределенными данными.
Здесь также рассматриваются методы перевода монолитного приложения на микро-
сервисную архитектуру. Но эта книга не манифест микросервисов. Она организована
в виде набора шаблонов проектирования. Каждый шаблон — это универсальное ре
шение проблемы, возникающей в определенном контексте. Красота такого подхода
состоит в том, что наряду с преимуществами того или иного решения описываются
и его недостатки, которые следует учитывать при работе. По своему опыту могу
сказать, что объективность при обдумывании решения дает гораздо лучшие резуль
таты, чем ее отсутствие. Надеюсь, вы получите удовольствие от чтения и научитесь
успешно разрабатывать микросервисы.
Благодарности
Труд писателя требует уединения, но, чтобы превратить рукопись в готовую книгу,
нужно множество людей.
Прежде всего я хочу поблагодарить Эрвина Тухейя (Erin Twohey) и Майкла
Стивенса (Michael Stevens) из издательства Manning за то, что они неизменно
одобряют идею написания очередной книги. Также хотел бы сказать спасибо ре
дакторам-консультантам Синтии Кейн (Cynthia Капе) и Марине Майклс (Marina
Michaels). Синтия помогла мне начать и поработала над несколькими первыми гла
вами. Дальше эстафету приняла Марина, и с ней мы дошли до конца. Я всегда буду
ей признателен за скрупулезную и конструктивную критику написанного. Хотел бы
выразить благодарность и остальным сотрудникам Manning, которые работали над
изданием этой книги.
Спасибо научному редактору Кристиану Меннерику (Christian Mennerich), кор
ректору Энди Майлзу (Andy Miles) и всем внештатным рецензентам: Энди Киршу
(Andy Kirsch), Антонио Пессолано (Antonio Pessolano), Apery Мелик-Адамяну
(Areg Melik-Adamyan), Кейджу Слагелю (Cage Slagel), Карлосу Куротто (Carlos
Curotto), Дрору Хелперу (Dror Helper), Эросу Педрини (Eros Pedrini), Хьюго Крузу
(Hugo Cruz), Ирине Романенко (Irina Romanenko), Джесси Росалье (Jesse Rosalia),
Джо Джастесену (Joe Justesen), Джону Гатри (John Guthrie), Кирти Шетти (Keerthi
Shetty), Мишель Мауро (Michele Mauro), Полу Гребенцу (Paul Grebenc), Петуру
Раджу (Pethuru Raj), Потито Колучелли (Potito Coluccelli), Шобхе Айер (Shobha
Iyer), Симеону Лейзерзону (Simeon Leyzerzon), Срихари Сридгарану (Srihari
Sridharan), Тиму Муру (Tim Moore), Тони Суитсу (Tony Sweets), Тренту Уайтли
(Trent Whiteley), Весу Шаддиксу (Wes Shaddix), Уильяму И. Уилеру (William
Е. Wheeler) и Золтану Хамори (Zoltan Hamori).
Я хотел бы также поблагодарить всех, кто купил МЕАР и оставил свои отзывы
на форуме или связался со мной напрямую.
Спасибо организаторам и участникам всех конференций и встреч, на которых
я выступал, за шанс представить и проверить мои идеи. И спасибо моим клиентам
по всему миру, для которых я проводил консультации и тренинги, за возможность
им помочь и претворить мои задумки в жизнь.
20 Благодарности
Хочу выразить благодарность моим коллегам по Eventuate, Inc., Эндрю, Вален
тину, Артему и Станиславу, за их вклад в продукт, над которым работает Eventuate,
и проекты с открытым исходным кодом.
Наконец, я хотел бы сказать спасибо жене Лауре и детям Элли, Томасу и Джанет
за поддержку и понимание на протяжении последних 18 месяцев. Будучи прико
ванным к своему ноутбуку, я не мог ходить на футбольные матчи Элли, наблюдать
за тем, как Томас учится летать на авиасимуляторе, и посещать новые рестораны
вместе с Джанет.
Спасибо вам всем!
О книге
Цель этой книги — научить вас успешно разрабатывать приложения с использова
нием микросервисной архитектуры.
Здесь обсуждаются не только преимущества, но и недостатки микросервисов.
Вы узнаете, в каких ситуациях имеет смысл применять их, а когда лучше подумать
о монолитном подходе.
Кому следует прочитать эту книгу
Основное внимание в книге уделяется архитектуре и разработке. Она рассчитана на
любого, в чьи обязанности входят написание и доставка программного обеспечения,
в том числе на разработчиков, архитекторов, технических директоров и начальников
отделов по разработке.
Акцент здесь делается на шаблонах микросервисной архитектуры и других кон
цепциях. Я старался сделать материал доступным вне зависимости от того, какой
стек технологий вы используете. Вам лишь нужно познакомиться с основами архи
тектуры и проектирования приложений уровня предприятия. В частности, понадо
бятся понимание таких концепций, как трехуровневая архитектура, проектирование
веб-приложений, реляционные базы данных, межпроцессное взаимодействие на
основе обмена сообщениями и REST, а также базовые знания программной безопас
ности. Код примеров создан на языке Java. Чтобы извлечь из примеров максималь
ную пользу, вы должны быть знакомы с фреймворком Spring.
Структура издания
Книга состоит из 13 глав.
□ Глава 1 описывает симптомы монолитного ада, которые проявляются, когда моно
литное приложение перерастает свою архитектуру, и дает советы относительно того,
22 О книге
как выйти из этой ситуации путем перехода на микросервисную архитектуру. В ней
также предоставлен краткий обзор терминов, которые используются в шаблонах
проектирования микросервисов и упоминаются на протяжении почти всей книги.
□ Глава 2 объясняет, почему программная архитектура имеет большое значение,
и описывает шаблоны, с помощью которых приложение можно разбить на от
дельные сервисы. Кроме того, в ней показаны способы преодоления проблем,
с которыми разработчики обычно сталкиваются на этом пути.
□ В главе 3 описываются разные шаблоны для организации надежного межпроцесс
ного взаимодействия в рамках микросервисной архитектуры. Из нее вы узнаете,
почему асинхронное взаимодействие, основанное на обмене сообщениями, часто
наилучший выбор.
□ В главе 4 показано, как поддерживать согласованность данных между сервисами
с помощью шаблона «Повествование» (Saga). Повествование (иногда встречается
термин «сага») — это последовательность локальных транзакций, которые коор
динируются с помощью асинхронного обмена сообщениями.
□ Глава 5 описывает процесс построения бизнес-логики сервиса с использованием
предметно-ориентированного проектирования, шаблонов агрегирования и до
менных событий.
□ Глава 6 является логическим продолжением главы 5. В ней рассказывается, как
разработать бизнес-логику с помощью шаблона порождения событий, который
представляет собой метод структурирования бизнес-логики и постоянного хра
нения доменных объектов.
□ Глава 7 посвящена реализации запросов для извлечения данных, разбросанных
по разным сервисам. Мы рассмотрим два разных шаблона: объединение API
и CQRS (command query responsibility segregation).
□ Глава 8 охватывает шаблоны проектирования внешних API для обработки за
просов от разного рода клиентов, таких как мобильные приложения, браузерные
JavaScript-приложения и сторонние программы.
□ Глава 9 — первая из двух глав, посвященных методикам автоматического тести
рования микросервисов. В ней вы познакомитесь с такими важными понятиями,
как пирамида тестирования, описывающая относительные пропорции каждого
из типов тестов в вашем тестовом наборе. Вы также научитесь писать модульные
тесты, которые станут фундаментом пирамиды тестирования.
□ Глава 10 — логическое продолжение главы 9. Она посвящена написанию тестов
других типов, входящих в пирамиду тестирования, включая интеграционные
и компонентные тесты, а также проверку потребительских контрактов.
□ В главе 11 освещаются различные аспекты разработки сервисов промышленного
уровня, включая безопасность, шаблон вынесения конфигурации вовне и ша
блоны наблюдаемости сервисов. В число последних входят агрегация журналов,
метрики приложения и распределенная трассировка.
Об авторе 23
□ В главе 12 описываются различные методы развертывания сервисов, включая
виртуальные машины, контейнеры и бессерверные платформы. В ней также
раскрываются преимущества использования сети сервисов — слоя сетевого
программного обеспечения, который служит посредником при взаимодействии
в рамках микросервисной архитектуры.
□ В главе 13 объясняется, как шаг за шагом превратить монолитную архитектуру
в микросервисную путем применения шаблона «Подавляющее приложение»
(Strangler application). Это подразумевает реализацию новых возможностей
в виде сервисов и извлечение модулей из монолитной системы с их последующим
преобразованием в сервисы.
По мере чтения вы познакомитесь с разными аспектами микросервисной архи
тектуры.
О коде
Эта книга содержит множество примеров исходного кода в виде пронумерованных
листингов и небольших фрагментов-вставок. В обоих случаях для форматирова
ния используется такой моноширинный шрифт, позволяющий отделить код от осталь
ного текста. Во многих случаях оригинальный исходный код был переформати
рован — издатель добавил переносы строк и убрал отступы, чтобы оптимально
использовать место на странице. Кроме того, из большей части листингов были
удалены комментарии, если код описывается в тексте. Нередко код содержит по
яснения, которые выделяют важные концепции.
Все главы, кроме 1, 2 и 13, содержат код из сопутствующей демонстрационной
программы, которая находится в репозитории GitHub: github.com/microservices-patterns/
ftgo-application.
Онлайн-ресурсы
Еще одним отличным ресурсом для изучения микросервисной архитектуры явля
ется мой сайт microservices, io. Он содержит не только полный набор шаблонов проек
тирования, но и ссылки на другие ресурсы, включая статьи, презентации и примеры
кода.
Об авторе
Крис Ричардсон (Chris Richardson) — разработчик, архитектор и автор книги
POJOs in Action (Manning, 2006), в которой описывается процесс построения Java-
приложений уровня предприятия с помощью фреймворков Spring и Hibernate.
Он носит почетные звания Java Champion и JavaOne Rock Star.
24 О книге
Крис разработал оригинальную версию CloudFoundry.com — раннюю реализа
цию платформы Java PaaS для Amazon ЕС2.
Ныне он считается признанным идейным лидером в мире микросервисов и регу
лярно выступает на международных конференциях. Крис создал сайт microservices.io,
на котором собраны шаблоны проектирования микросервисов. А еще он проводит
по всему миру консультации и тренинги для организаций, которые переходят на
микросервисную архитектуру. Сейчас Крис работает над своим третьим стартапом
Eventuate.io. Это программная платформа для разработки транзакционных микро
сервисов.
Об иллюстрации на обложке
Рисунок на обложке называется «Одеяние раба-мориска в 1568 году»1 и поза
имствован из четырехтомника Томаса Джеффериса (Thomas Jefferys) A Collection
of the Dresses of Different Nations, Ancient and Modem («Коллекция платья разных
народов, старинного и современного»), изданного в Лондоне в 1757-1772 годах.
На титульном листе говорится, что это гравюра, раскрашенная вручную с приме
нением гуммиарабика.
Томаса Джеффериса (1719-1771) называли географом при короле Георге III.
Он был английским картографом и ведущим поставщиком карт своего времени.
Он чертил и печатал карты для правительства и других государственных органов.
На его счету целый ряд коммерческих карт и атласов, в основном Северной Аме
рики. Занимаясь картографией в разных уголках мира, он интересовался местны
ми традиционными нарядами, которые впоследствии были блестяще переданы
в данной коллекции. В конце XVIII века заморские путешествия для собственного
удовольствия были относительно новым явлением, поэтому подобные коллекции
пользовались популярностью, позволяя получить представление о жителях других
стран как настоящим туристам, так и «диванным» путешественникам.
Разнообразие иллюстраций в книгах Джеффериса — яркое свидетельство уни
кальности и оригинальности народов мира в то время. С тех пор тенденции в одежде
сильно изменились, а региональные и национальные различия, такие значимые
200 лет назад, постепенно сошли на нет. В наши дни бывает сложно отличить друг
от друга жителей разных континентов. Если взглянуть на это с оптимистичной
точки зрения, мы пожертвовали культурным и внешним разнообразием в угоду
более насыщенной личной жизни или в угоду более разнообразной и интересной
интеллектуальной и технической деятельности.
В то время как большинство компьютерных изданий мало чем отличаются друг
от друга, компания Manning выбирает для своих книг обложки, основанные на бо
гатом региональном разнообразии, которое Джефферис воплотил в иллюстрациях
два столетия назад. Это ода находчивости и инициативности современной компью
терной индустрии.
Habit of a Morisco Slave in 1568.
От издательства 25
От издательства
Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com (из
дательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На веб-сайте издательства www.piter.com вы найдете подробную информацию
о наших книгах.
Побег
из монолитного ада
Понедельник превосходно начался для Мэри, технического директора компании
Food to Go, Inc. (FTGO), но уже к обеденному перерыву она почувствовала разо
чарование. Мэри провела предыдущую неделю вместе с другими архитекторами
и разработчиками программного обеспечения на отличной конференции, знако
мясь с последними веяниями в мире программирования, включая непрерывное
развертывание и микросервисную архитектуру. Она встретилась с сокурсниками
по университету Северной Каролины и поделилась с ними историями о борьбе за
технологическое лидерство. Конференция укрепила ее в желании улучшить процесс
разработки в FTGO.
К сожалению, это чувство быстро испарилось. Она только что потратила первое
утро новой недели на неприятное совещание с бизнес-руководством и старшими
разработчиками. На протяжении двух часов они обсуждали очередную задержку
1.1. Медленным шагом в монолитный ад 27
в выпуске критически важной версии продукта. Увы, но последние несколько лет
подобного рода совещания проходили все чаще и чаще. Несмотря на использование
гибких методик, темп разработки продолжил замедляться, делая бизнес-требования
почти невыполнимыми. И что хуже всего, простого решения, по всей видимости,
не было.
Благодаря конференции Мэри осознала, что ее компания столкнулась с пробле
мой монолитного ада и что панацеей станет переход на микросервисную архитекту
ру. Но такой подход и сопутствующие ему новейшие методики разработки казались
недостижимой мечтой. Мэри сложно было представить, как она будет исправлять
текущую ситуацию и одновременно улучшать процесс разработки в FTGO.
К счастью, как вы вскоре сможете убедиться, это можно сделать. Но сначала
рассмотрим проблемы, с которыми сталкивается компания FTGO, и попытаемся
понять, что их вызвало.
1.1. Медленным шагом в монолитный ад
С момента основания в 2005 году компания FTGO демонстрировала стремительный
рост. Сейчас она входит в число лидеров на американском рынке доставки еды.
У руководства даже имеются планы расширения за рубеж, но они находятся под
угрозой срыва из-за задержек в реализации необходимых возможностей.
По своей сути приложение FTGO довольно простое. Клиенты заказывают еду
в местных ресторанах на сайте компании или с помощью мобильного приложения.
FTGO координирует сеть курьеров, которые доставляют заказы. Компания также
отвечает за оплату услуг курьеров и ресторанов. Последние с помощью веб-сайта
FTGO редактируют меню и управляют заказами. Приложение использует различные
веб-сервисы: Stripe для платежей, Twilio для обмена сообщениями и Amazon Simple
Email Service (SES) для электронной почты.
Как и многие другие устаревающие промышленные приложения, FTGO пред
ставляет собой монолит, состоящий из единого файла в формате Java WAR (Web
Application Archive). С годами это приложение стало большим и сложным. Несмо
тря на все усилия команды разработчиков, оно превратилось в живую иллюстрацию
антишаблона под названием «большой комок грязи» (Big Ball of Mud; www.laputan.org/
mud/, https://ru.Wikipedia.огдМ|к'|/Большой_комок_грязи). Цитируя Фута и Йодера, авторов
этого антишаблона, это «беспорядочно структурированные, растянутые, неряш
ливые, словно перемотанные на скорую руку изоляционной лентой и проводами,
джунгли спагетти-кода». Темпы выпуска новых версий замедлились. Но что еще
хуже, приложение FTGO было написано с использованием устаревших фреймвор
ков. Налицо все симптомы монолитного ада.
В следующем подразделе описывается архитектура приложения FTGO. В нем
также объясняется, почему монолитная архитектура хорошо себя проявляла в самом
начале. Мы поговорим о том, как приложение FTGO переросло свою архитектуру
и каким образом это привело к монолитному аду.
28 Глава 1 • Побег из монолитного ада
1.1.1. Архитектура приложения FTGO
FTGO — это типичное Java-приложение уровня предприятия. Его архитектура,
выполнена в виде шестиугольника (рис. 1.1). Мы подробнее обсудим этот архитек
турный стиль в главе 2. В гексагональной архитектуре ядром приложения является
бизнес-логика, которую окружают различные адаптеры, реализующие пользователь
ский интерфейс и выполняющие интеграцию с внешними системами.
Рис. 1.1. Приложение FTGO имеет гексагональную архитектуру. Оно состоит из бизнес-логики,
окруженной адаптерами, которые реализуют пользовательские интерфейсы и взаимодействуют
с внешними системами, например мобильными клиентами и облачными сервисами для платежей,
электронной почты и обмена сообщениями
Бизнес-логика состоит из модулей, каждый из которых представляет собой на
бор доменных объектов. В качестве примеров модулей можно привести управление
заказами, управление доставкой, биллинг и платежи. Здесь также есть несколько
адаптеров, взаимодействующих с внешними системами. Некоторые адаптеры на
правлены вовнутрь и обслуживают запросы путем обращения к бизнес-логике — это
относится к REST API и пользовательскому веб-интерфейсу. Остальные адаптеры
направлены вовне, позволяя бизнес-логике получать доступ к MySQL и работать
с такими облачными сервисами, как Twilio и Stripe.
1.1. Медленным шагом в монолитный ад 29
Несмотря на то что приложение FTGO имеет логически модульную структуру,
оно упаковано в единый WAR-файл. Это пример широко распространенного моно
литного стиля программной архитектуры, в соответствии с которым система струк
турируется в виде одного исполняемого файла или развертываемого компонента.
Если бы приложение FTGO было написано на языке Go (GpLang), это был бы один
исполняемый файл. В случае с Ruby или NodeJS это было бы единое дерево катало
гов с исходным кодом. В монолитной архитектуре как таковой нет ничего плохого.
Разработчики FTGO приняли верное решение, выбрав ее для своего приложения.
1.1.2. Преимущества монолитной архитектуры
На ранних этапах развития приложение FTGO было относительно небольшим, по
этому монолитная архитектура давала ему множество преимуществ.
□ Простота разработки — IDE и другие инструменты разработки сосредоточены
на построении единого приложения.
□ Легкость внесения радикальных изменений — вы можете поменять код и структуру
базы данных, а затем собрать и развернуть полученный результат.
□ Простота тестирования — разработчики написали сквозные тесты, которые
запускали приложение, обращались к REST API и проверяли пользовательский
интерфейс с помощью Selenium.
□ Простота развертывания — разработчику достаточно было скопировать WAR-
файл на сервер с установленной копией Tomcat.
□ Легкость масштабирования — компания FTGO запускала несколько экземпля
ров приложения, размещенных за балансировщиком нагрузки.
Однако со временем разработка, тестирование и масштабирование существенно
усложнились. Посмотрим почему.
1.1.3. Жизнь в монолитном аду
К своему сожалению, разработчики FTGO обнаружили, что у такого подхода есть
огромный недостаток. Успешные приложения, такие как FTGO, имеют склонность
вырастать из монолитной архитектуры. Каждый раз команда разработчиков FTGO
реализовывала несколько новых возможностей, увеличивая тем самым кодовую
базу. Более того, успех компании постепенно приводил к росту числа программи
стов. А это не только ускорило темпы разрастания кодовой базы, но и повысило
накладные расходы на администрирование.
Как видно на рис. 1.2, с годами небольшое простое приложение FTGO превра
тилось в чудовищный монолит. Точно так же некогда компактная команда разра
ботчиков теперь состоит из нескольких scrum-команд, каждая из которых работает
над конкретной функциональной областью. Переросшее свою архитектуру прило
жение FTGO попало в монолитный ад. Разработка стала медленной и мучительной.
30 Глава 1 • Побег из монолитного ада
Разрабатывать и развертывать код с применением гибких методов больше невоз
можно. Посмотрим, почему так получилось.
Рис. 1.2. Пример монолитного ада. Большая команда разработчиков FTGO сохраняет
свои изменения в едином репозитории исходного кода. Между сохранением и попаданием
кода в промышленную среду лежит длинный и тяжелый путь, который подразумевает
ручное тестирование. Приложение FTGO стало большим, сложным, ненадежным и трудным
в обслуживании
Высокая сложность пугает разработчиков
Основная проблема приложения FTGO заключается в его чрезмерной сложности.
Оно слишком большое для того, чтобы один разработчик мог его понять. В итоге
исправление ошибок и реализация новых возможностей усложнились и стали за
нимать много времени. Разработчики не успевали в срок.
Усугубляет проблему то, что сложность, и так чрезмерная, обычно повышается
экспоненциально. Если кодовая база плохо поддается пониманию, разработчик
не сможет внести изменения подходящим образом. Каждое изменение усложняет
код и делает его еще менее понятным. Элегантная модульная архитектура, пока
занная на рис. 1.1, не отражает действительности. Приложение FTGO постепенно
превращается в большой комок грязи.
Мэри вспомнила, что на конференции, где недавно присутствовала, она встре
тила разработчика, который писал инструмент для анализа зависимостей между
тысячами JAR-файлов в приложении, состоящем из миллиона строчек кода. Тогда
ей показалось, что этот инструмент можно будет применить для FTGO. Теперь она
в этом не уверена. Что-то подсказывает Мэри, что лучшим решением будет переход
на архитектуру, более приспособленную к крупным приложениям, — микросервисы.
1.1. Медленным шагом в монолитный ад 31
Медленная разработка
Помимо борьбы с чрезмерной сложностью, разработчикам FTGO приходится иметь
дело с замедлением ежедневных технических задач. Большое приложение пере
гружает и замедляет их IDE. Сборка кода занимает много времени. Более того,
из-за своей величины приложение долго запускается. В итоге затягивается цикл
написания — сборки — запуска — тестирования кода, что плохо сказывается на
продуктивности.
Длинный и тяжелый путь от сохранения изменений
до их развертывания
Еще одна проблема с приложением FTGO состоит в том, что доставка изменений
в промышленную среду является долгим и тяжелым процессом. Команда разработ
чиков обычно развертывает обновления раз в месяц, как правило в ночь пятницы
или субботы. Мэри постоянно читает о том, что новейшим подходом к обслужива
нию приложений типа SaaS (Software-as-a-Service) является непрерывное развер
тывание'. доставка изменений в промышленную среду многократно в течение суток
и в рабочее время. Как сообщается, по состоянию на 2011 год компания Amazon.com
развертывала изменения каждые 11,6 с, никак не затрагивая при этом конечного
пользователя! Для разработчиков FTGO обновление продукта чаще чем раз в месяц
кажется несбыточной мечтой. А использование непрерывного развертывания вы
глядит чем-то нереальным.
Компания FTGO частично применяет гибкую методологию разработки. Команда
разработчиков делится на группы и выполняет двухнедельные «спурты». К сожале
нию, путь готового кода к промышленной среде оказывается длинным и тяжелым.
Работа такого большого количества программистов над одной и той же кодовой
базой часто приводит к тому, что сборку нельзя выпустить. Разработчики FTGO
пытались решить эту проблему, создавая отдельные ветви для новых возможно
стей, но это вылилось в затяжные и мучительные слияния кода. Как следствие,
по окончании «спурта» следует длительный период тестирования и стабилизации
кодовой базы.
Еще одна причина того, почему изменения так долго доходят до промышленной
среды, связана с длительным тестированием. Код настолько сложен, а эффект от
внесенного изменения так неочевиден, что разработчикам и серверу непрерывной
интеграции (Continuous Integration, CI) приходится выполнять весь набор тестов.
Некоторые участки системы даже требуют ручного тестирования. Кроме того, зна
чительное время затрачивается на диагностику и исправление причин проваленных
тестов. В итоге на завершение цикла тестирования требуется несколько дней.
Трудности с масштабированием
Команда FTGO испытывает проблемы и с масштабированием своего приложения.
Дело в том, что требования к ресурсам разных программных модулей конфликтуют
между собой. Например, данные о ресторанах хранятся в большой резидентной базе,
которую желательно развертывать на серверах с большим объемом оперативной
32 Глава 1 • Побег из монолитного ада
памяти. Для сравнения: модуль обработки изображений сильно нагружает ЦПУ
и в идеале должен работать на серверах с большими вычислительными ресурсами.
Но, поскольку эти модули входят в одно и то же приложение, компании приходится
идти на компромисс при выборе серверной конфигурации.
Сложно добиться надежности монолитного приложения
Еще одна проблема с приложением FTGO заключается в его недостаточной на
дежности. В результате в работе промышленной среды часто возникают перебои.
Одна из причин — то, что из-за большого размера приложения его сложно как сле
дует протестировать. Недостаточное тестирование означает, что ошибки попадают
в итоговую версию программы. Что еще хуже, приложению не хватает локализа
ции неисправностей, поскольку все модули выполняются внутри одного процесса.
Время от времени ошибка в одном модуле (например, утечка памяти) приводит
к поочередному сбою всех экземпляров системы. Разработчикам FTGO не очень
нравится, когда их вызывают посреди ночи из-за поломки в промышленной среде.
Недовольно и руководство, ведь при этом страдают доходы и доверие к компании.
Зависимость от постепенно устаревающего стека технологий
и последнее, с чем столкнулась команда FTGO, — то, что архитектура, обусло
вившая монолитный ад, заставляет использовать постепенно устаревающий стек
технологий. При этом разработчикам сложно переходить на новые фреймворки
и языки программирования. Переписать все монолитное приложение, применив
новые и, предположительно, лучшие технологии, было бы чрезвычайно дорого
и рискованно. Как следствие, программистам приходится работать с теми инстру
ментами, которые они выбрали при запуске проекта. Из-за этого они часто обязаны
поддерживать код, написанный с помощью устаревших средств.
Фреймворк Spring продолжает развиваться, поддерживая обратную совмести
мость, поэтому теоретически у команды FTGO должна быть возможность перехода
на новые версии. К сожалению, в приложении используются версии фреймворков,
несовместимые с новейшими выпусками Spring. Разработчики так и не нашли вре
мени для их обновления. В итоге существенная часть кода написана с применением
устаревших технологий. Кроме того, программистам хотелось бы поэксперименти
ровать с языками, не основанными на JVM, такими как Go Lang или NodeJS. К со
жалению, монолитная архитектура исключает такую возможность.
1.2. Почему эта книга актуальна для вас
Вполне вероятно, что вы разработчик, архитектор, технический директор или на
чальник отдела разработки. И отвечаете за приложение, которое переросло моно
литную архитектуру. Как и Мэри из FTGO, вы испытываете сложности с доставкой
программного обеспечения и хотите узнать, как уйти из монолитного ада. Или, воз
можно, боитесь, что ваша организация уже на пути к монолитному аду, и хотите
1.3. Чему вы научитесь, прочитав эту книгу 33
как-то изменить курс, пока еще не слишком поздно. Если вы хотите избежать такой
ситуации или выбраться из нее, эта книга для вас.
Большое внимание здесь уделяется концепциям микросервисной архитектуры.
Я стремился сделать материал доступным вне зависимости от того, какой стек техно
логий вы используете. Но нужно, чтобы вы были знакомы с основами архитектуры
и проектирования приложений уровня предприятия, в частности знали:
□ трехуровневую архитектуру;
□ проектирование веб-приложений;
□ разработку бизнес-логики в объектно-ориентированном стиле;
□ применение реляционных СУБД — SQL- и ACID-транзакции;
□ межпроцессное взаимодействие с использованием брокера сообщений и REST API;
□ безопасность, включая аутентификацию и авторизацию.
Примеры кода в этой книге выполнены на языке Java с применением фреймвор
ка Sping. Поэтому вы должны быть знакомы с этим фреймворком, чтобы извлечь
максимальную пользу из представленного кода.
1.3. Чему вы научитесь,
прочитав эту книгу
Дойдя до последней страницы, вы усвоите такие темы.
□ Основные характеристики микросервисной архитектуры, ее достоинства и недо
статки, а также сценарии, в которых ее следует использовать.
□ Методы работы с распределенными данными.
□ Эффективные стратегии тестирования микросервисов.
□ Варианты развертывания микросервисов.
□ Стратегии перевода монолитного приложения на микросервисную архитектуру.
Вы также получите следующие навыки.
□ Проектирование приложений с применением микросервисной архитектуры.
□ Разработка бизнес-логики для микросервисов.
□ Использование повествований для обеспечения согласованности данных между
сервисами.
□ Реализация запросов, охватывающих несколько сервисов.
□ Эффективное тестирование микросервисов.
□ Разработка безопасных, настраиваемых и наблюдаемых сервисов, готовых к про
мышленному применению.
□ Разбиение существующих монолитных приложений на сервисы.
34 Глава 1 Побег из монолитного ада
1.4. Микросервисная архитектура спешит
на помощь
Мэри пришла к выводу, что ее компания должна перейти на микросервисную ар
хитектуру.
Что интересно, программная архитектура имеет мало общего с функциональны
ми требованиями. Вы можете реализовать набор сценариев (функциональных требо
ваний к приложению) с использованием любой архитектуры. На самом деле таким
успешным приложениям, как FTGO, свойственно быть большими и монолитными.
Конечно, архитектура тоже важна, ведь она определяет так называемые требова
ния к качеству обслуживания, известные также как нефункциональные требования
или атрибуты качества. Рост приложения FTGO сказался на различных его атри
бутах качества, особенно на тех, которые влияют на скорость доставки программного
обеспечения: обслуживаемости, расширяемости и тестируемости.
С одной стороны, дисциплинированная команда способна замедлить процесс ска
тывания в монолитный ад. Программисты могут усердно поддерживать модульность
своего приложения. А еще — написать комплексные автоматические тесты. С другой
стороны, у них не получится избежать проблем, свойственных большим командам,
которые работают над одной монолитной кодовой базой. Они также не смогут ничего
поделать с постоянно устаревающим стеком технологий. В их власти лишь отсрочить
неизбежное. Чтобы убежать из монолитного ада, придется мигрировать на новую,
микросервисную архитектуру.
Сегодня все больше специалистов сходятся на том, что при построении крупного,
сложного приложения следует задуматься об использовании микросервисов. Но что
именно мы имеем в виду под микросервисами? К сожалению, само название мало
о чем говорит, так как основной акцент в нем делается на размере. У микросервисной
архитектуры есть бесчисленное количество определений. Одни понимают название
слишком буквально и утверждают, что сервис должен быть крошечным, например
состоять из 100 строчек кода. Другие считают, что на разработку сервиса должно
уходить не более двух недель. Адриан Кокрофт (Adrian Cockcroft), ранее работавший
в Netflix, определяет микросервисную архитектуру как сервис-ориентированную,
состоящую из слабо связанных элементов с ограниченным контекстом. Это не
плохое определение, но ему недостает ясности. Попробуем придумать что-нибудь
получше.
1.4.1. Куб масштабирования и микросервисы
Мое определение микросервисной архитектуры навеяно прекрасной книгой Марти
на Эбботта (Martin Abbott) и Майкла Фишера (Michael Fisher) The Art of Scalability
(Addison-Wesley, 2015). В ней описывается практичная трехмерная модель масшта
бирования в виде куба (рис. 1.3).
Эта модель определяет три направления для масштабирования приложе
ний: X, Y и Z.
1.4. Микросервисная архитектура спешит на помощь 35
Рис. 1.3. Модель определяет три направления для масштабирования приложения:
масштабирование по оси X распределяет нагрузку между несколькими идентичными экземплярами,
по оси Z — направляет запросы в зависимости от их атрибутов, ось Y разбивает приложение
на сервисы с разными функциями
Масштабирование по оси X распределяет запросы
между несколькими экземплярами
Масштабирование по оси X часто применяют в монолитных приложениях. Принцип
работы этого подхода показан на рис. 1.4. Запускаются несколько экземпляров про
граммы, размещенных за балансировщиком нагрузки. Балансировщик распределяет
запросы между N одинаковыми экземплярами. Это отличный способ улучшить
мощность и доступность приложения.
Масштабирование по оси Z направляет запросы
в зависимости от их атрибутов
Масштабирование по оси Z тоже предусматривает запуск нескольких экземпляров
монолитного приложения, но в этом случае, в отличие от масштабирования по
оси X, каждый экземпляр отвечает за определенное подмножество данных (рис. 1.5).
Маршрутизатор, выставленный впереди, задействует атрибут запроса, чтобы на
править его к подходящему экземпляру. Для этого, к примеру, можно использовать
поле userid.
36 Глава! • Побег из монолитного ада
Рис. 1.4. Масштабирование по оси X связано с запуском нескольких идентичных экземпляров
монолитного приложения, размещенных за балансировщиком нагрузки
Рис. 1.5. Масштабирование по оси Z связано с запуском нескольких идентичных экземпляров
монолитного приложения, размещенных за маршрутизатором, который направляет запросы
в зависимости от их атрибутов. Каждый экземпляр отвечает за подмножество данных
В данном сценарии каждый экземпляр приложения отвечает за подмножество
пользователей. Маршрутизатор проверяет поле userid, указанное в заголовке запро
са Authorization, чтобы выбрать одну из N идентичных копий программы. Масшта
бирование по оси Z отлично помогает справиться с участившимися транзакциями
и растущими объемами данных.
1.4. Микросервисная архитектура спешит на помощь 37
Масштабирование по оси Y разбивает приложение на сервисы
с разными функциями
Масштабирование по осям X и Zувеличивает мощность и доступность приложения.
Но ни один из этих подходов не решает проблем с усложнением кода и процесса раз
работки. Чтобы справиться с ними, следует применить масштабирование по оси У,
или функциональную декомпозицию (разбиение). То, как это работает, показано на
рис. 1.6: монолитное приложение разбивается на отдельные сервисы.
Рис. 1.6. Масштабирование по оси Y разбивает приложение на отдельные сервисы. Каждый из них
отвечает за определенную функцию и масштабируется по оси X (а также, возможно, по оси Z)
Сервис — это мини-приложение, реализующее узкоспециализированные функ
ции, такие как управление заказами, управление клиентами и т. д. Сервисы масшта
бируются по оси X, некоторые из них могут использовать также ось Z. Например,
сервис Order имеет несколько копий, нагрузка на которые балансируется.
Обобщенное определение микросервисной архитектуры (или микросервисов)
звучит так: это стиль проектирования, который разбивает приложение на отдельные
сервисы с разными функциями. Заметьте, что размер здесь вообще не упоминается.
Главное, чтобы каждый сервис имел четкий перечень связанных между собой обя
занностей. Позже мы поговорим о том, что это означает.
Теперь рассмотрим микросервисную архитектуру как разновидность модульности.
1.4.2. Микросервисы как разновидность модульности
Модульность является неотъемлемой частью разработки крупных сложных прило
жений. Современные приложения, такие как FTGO, слишком велики для разработ
ки в одиночку и слишком сложны для того, чтобы в них мог разобраться отдельный
38 Глава 1 • Побег из монолитного ада
человек. Приложения следует разбивать на модули, которые разрабатывают и в ко
торых разбираются разные люди. В монолитном проекте модули представляют собой
сочетание концепций языка программирования, таких как пакеты в Java, и ресур
сов, участвующих в сборке, таких как JAR-файлы. Но, как выяснили разработчики
FTGO, этот подход обычно не очень практичен. Монолитные приложения с длин
ным жизненным циклом, как правило, превращаются в «большие комки грязи».
В микросервисной архитектуре единицей модульности является сервис. Серви
сы обладают API, которые служат непроницаемым барьером. В отличие от пакетов
в Java API нельзя обойти, чтобы обратиться к внутреннему классу. В долгосрочной
перспективе это намного упрощает поддержание модульности приложения. Исполь
зование сервисов в качестве строительных блоков имеет и другие преимущества,
например, каждый из них можно развертывать и масштабировать отдельно.
1.4.3. У каждого сервиса есть своя база данных
Ключевой особенностью микросервисной архитектуры является то, что сервисы
слабо связаны между собой и взаимодействуют только через API. Слабой связан
ности можно достичь за счет выделения каждому сервису отдельной базы данных.
Например, в онлайн-магазине сервисы Order и Customer могли бы иметь собственные
базы данных с таблицами ORDERS и CUSTOMERS соответственно. Структуру данных
сервиса можно менять на этапе разработки, не координируя свои действия с раз
работчиками других сервисов. На этапе выполнения сервисы изолированы друг
от друга — ни одному из них, например, не придется ждать из-за того, что другой
сервис заблокировал БД.
Разобравшись с тем, что такое микросервисы и каковы их ключевые характери
стики, можем посмотреть, как это все относится к приложению FTGO.
1.4.4. Микросервисная архитектура для FTGO
В дальнейшем на страницах книги мы будем подробно обсуждать микросервисную
архитектуру приложения FTGO. Но сначала взглянем на то, как в этом контексте
выглядит масштабирование по оси У. Применив к FTGO декомпозицию, мы полу
чим архитектуру (рис. 1.7). Разбитый на части код состоит из многочисленных
сервисов, как клиентских (фронтенд), так и серверных (бэкенд). Мы можем также
провести масштабирование по осям X и Z, чтобы на этапе выполнения каждый сер
вис имел несколько экземпляров.
1.4. Микросервисная архитектура спешит на помощь 39
и
X
й.
к
се
рв
ис
ам
. С
ер
ви
сы
в
за
им
од
ей
ст
ву
ю
т
м
еж
ду
с
об
ой
ч
ер
ез
40 Глава 1 • Побег из монолитного ада
Клиентские сервисы включают в себя API-шлюз и пользовательский веб
интерфейс для ресторанов. API-шлюз, который, как вы увидите в главе 8, играет
роль фасада, предоставляет интерфейсы REST API, применяемые в мобильных
приложениях для заказчиков и курьеров. Веб-интерфейс используется ресторанами
для управления меню и обработки заказов.
Бизнес-логика приложения FTGO состоит из многочисленных бэкенд-сервисов.
У каждого из них есть REST API и собственная приватная база данных. В их число
входят такие сервисы:
□ Order — управляет заказами;
□ Delivery — управляет доставкой заказов из ресторана клиентам;
□ Restaurant — хранит информацию о ресторанах;
□ Kitchen — отвечает за подготовку заказов;
□ Accounting — управляет биллингом и платежами.
Многие сервисы являются аналогами модулей, описанных ранее в этой гла
ве. Разница в том, что все сервисы и их API имеют четкие определения. Каждый из
них можно разрабатывать, тестировать, развертывать и масштабировать независимо
от остальных. Такая архитектура помогает поддерживать модульность. Разработчик
не может обратиться к внутренним компонентам сервиса, минуя его API. В главе 13 вы
узнаете, как преобразовать существующее монолитное приложение в микросервисы.
1.4.5. Сравнение микросервисной
и сервис-ориентированной архитектур
Некоторые критики микросервисов утверждают, что в этом подходе нет ничего
нового и что это всего лишь разновидность сервис-ориентированной архитектуры
(service-oriented architecture, SOA). Действительно, на самом высоком уровне су
ществует некоторое сходство. И SOA, и микросервисная архитектура — это стили
проектирования, которые структурируют систему как набор сервисов. Но при более
детальном рассмотрении можно обнаружить существенные различия (табл. 1.1).
Таблица 1.1. Сравнение SOA и микросервисов
Параметр SOA Микросервисы
Межсервисное
взаимодействие
Умные каналы, такие как
сервисная шина предприятия,
с использованием тяжеловесных
протоколов вроде SOAP и других
веб-сервисных стандартов
Примитивные каналы, такие как
брокер сообщений, или прямое
взаимодействие между сервисами
с помощью легковесных протоколов
наподобие REST или gRPC
Данные Глобальная модель данных
и общие БД
Отдельные модель данных и БД
для каждого сервиса
Типовой сервис Крупное монолитное приложение Небольшой сервис
SOA и микросервисная архитектура обычно используют разные стеки техноло
гий. В приложениях на основе SOA, как правило, применяются тяжеловесные стан-
1.5. Достоинства и недостатки микросервисной архитектуры 41
дарты веб-сервисов наподобие SOAP. Им часто нужна сервисная шина предприятия
(Enterprise Service Bus, ESB) — умный канал, который интегрирует сервисы с по
мощью бизнес-логики и кода для обработки сообщений. Приложения, спроектирован
ные в виде микросервисов, обычно задействуют легковесные технологии с открытым
исходным кодом. Сервисы взаимодействуют через примитивные каналы, такие как
брокеры сообщений или простые протоколы, подобные REST или gRPC.
SOA и микросервисная архитектура также по-разному обращаются с данны
ми. SOA-приложения обычно имеют глобальную модель данных и общую БД.
Для сравнения: как уже упоминалось, у каждого микросервиса есть собственная
база данных. Более того, в главе 2 вы увидите, что каждому сервису, как правило,
отводится отдельная доменная модель.
Еще одно важное различие между двумя архитектурами заключается в размере
сервисов. SOA обычно используется для интеграции крупных, сложных, моно
литных приложений. В микросервисной архитектуре сервисы не всегда являются
крошечными, но почти всегда они оказываются намного меньше. Так что большин
ство SOA-приложений состоит из нескольких больших частей, а микросервисные
проекты чаще всего разбиты на десятки или сотни мелких сервисов.
1.5. Достоинства и недостатки
микросервисной архитектуры
Начнем с достоинств, а затем рассмотрим недостатки.
1.5.1. Достоинства микросервисной архитектуры
Микросервисная архитектура имеет следующие преимущества.
□ Она делает возможными непрерывные доставку и развертывание крупных, слож
ных приложений.
□ Сервисы получаются небольшими и простыми в обслуживании.
□ Сервисы развертываются независимо друг от друга.
□ Сервисы масштабируются независимо друг от друга.
□ Микросервисная архитектура обеспечивает автономность команд разработчиков.
□ Она позволяет экспериментировать и внедрять новые технологии.
□ В ней лучше изолированы неполадки.
Рассмотрим каждое из этих преимуществ.
Делает возможными непрерывные доставку и развертывание
крупных, сложных приложений
Главное достоинство микросервисной архитектуры — возможность непрерывных
доставки и развертывания крупных, сложных приложений. Как вы увидите в раз
деле 1.7, непрерывные доставка и развертывание входят в DevOps — набор методик
42 Глава 1 Побег из монолитного ада
для быстрого, частого и надежного выпуска обновлений. Организации с высокопро
изводительным DevOps обычно не испытывают больших проблем с развертыванием
изменений в промышленной среде.
Три свойства микросервисной архитектуры делают возможными непрерывные
доставку и развертывание.
□ Она обеспечивает уровень тестируемости, необходимый для непрерывных до
ставки и развертывания. Автоматическое тестирование — ключевой аспект не
прерывных доставки и развертывания. Поскольку все сервисы в микросервисной
архитектуре относительно небольшого размера, написание автоматических тестов
значительно упрощается, а их выполнение занимает меньше времени. В резуль
тате приложение будет содержать меньше ошибок.
□ Она обеспечивает уровень развертываемости, необходимый для непрерывных
доставки и развертывания. Каждый сервис может быть развернут независимо
от других. Если разработчикам, которые занимаются сервисом, нужно выкатить
изменение, затрагивающее только этот сервис, они могут не координировать
свои действия с другими командами. Так что частое развертывание изменений
в промышленной среде намного упрощается.
□ Она позволяет сделать команды разработчиков автономными и слабо связанными
между собой. Вы можете организовать отдел разработки в виде набора неболь
ших команд (например, по принципу двух пицц). В этом случае каждая команда
полностью отвечает за разработку и развертывание одного или нескольких свя
занных между собой сервисов. Она может разрабатывать, развертывать и мас
штабировать свои сервисы независимо от остальных разработчиков (рис. 1.8).
Это значительно ускоряет темп разработки.
Возможность выполнять непрерывные доставку и развертывание обеспечивает
несколько бизнес-преимуществ.
□ Сокращается время выхода на рынок, что позволяет компании быстро реагиро
вать на отзывы своих клиентов.
□ Компания обеспечивает уровень надежности своих услуг, соответствующий со
временным ожиданиям.
□ Работники довольны, поскольку вместо тушения пожара они уделяют больше
времени выпуску новых возможностей.
Как результат, микросервисная архитектура стала неотъемлемой частью любой
компании, зависящей от программных технологий.
Все сервисы невелики и просты в обслуживании
Еще одним преимуществом микросервисной архитектуры является то, что каждый
сервис получается сравнительно небольшим. Разработчикам проще разобраться в таком
коде. Компактная кодовая база не замедляет работу IDE, что повышает продуктивность.
К тому же все сервисы запускаются намного быстрее, чем большой монолит, что тоже
делает разработку более продуктивной и ускоряет развертывание.
1.5. Достоинства и недостатки микросервисной архитектуры 43
Рис. 1.8. Приложение FTGO, основанное на микросервисах, состоит из набора слабо связанных
сервисов. Каждая команда разрабатывает, тестирует и развертывает сервисы независимо
от других
Независимое масштабирование сервисов
Каждый сервис в микросервисной архитектуре можно масштабировать отдельно,
используя клонирование (ось X) и секционирование (ось Z). Кроме того, любой
сервис можно развернуть на оборудовании, которое лучше всего отвечает его требо
ваниям к ресурсам. Для сравнения: в монолитной архитектуре компоненты с разным
потреблением ресурсов (например, одним нужен мощный процессор, а другим —
много памяти) должны развертываться вместе.
Лучшая изоляция неполадок
В микросервисной архитектуре лучше изолированы неполадки. Например, утечка
памяти у сервиса затронет только его, другие сервисы продолжат обрабатывать
запросы в обычном режиме. В монолитной же архитектуре вышедший из строя
компонент поломает всю систему.
44 Глава 1 • Побег из монолитного ада
Возможность экспериментировать
и внедрять новые технологии
Последним, но немаловажным аспектом является то, что микросервисная архитек
тура устраняет любую долгосрочную зависимость от стека технологий. В принципе,
при создании нового сервиса разработчики могут выбрать наиболее подходящие
язык и фреймворки. Во многих организациях этот выбор ограничен, но смысл в том,
что вы не зависите от принятых ранее решений.
Более того, поскольку сервисы невелики, вполне реально переписать их с по
мощью лучших языков и технологий. Если новая технология не оправдала ожи
даний, вы можете просто выбросить сделанное, не ставя под угрозу весь проект.
При использовании монолитной архитектуры все иначе: технологии, выбранные
в самом начале, существенно ограничивают возможность перехода в дальнейшем
на другие языки и фреймворки.
1.5.2. Недостатки микросервисной архитектуры
Очевидно, что идеальных технологий не существует и у микросервисной архитек
туры тоже есть ряд существенных недостатков и проблем. На самом деле большая
часть книги посвящена борьбе с этими изъянами. Но пусть они вас не смущают —
позже я объясню, как с ними справиться.
Далее приведены основные недостатки и проблемы микросервисной архитек
туры.
□ Сложно подобрать подходящий набор сервисов.
□ Сложность распределенных систем затрудняет разработку, тестирование и раз
вертывание.
□ Развертывание функций, охватывающих несколько сервисов, требует тщательной
координации.
□ Решение о том, когда следует переходить на микросервисную архитектуру, яв
ляется нетривиальным.
По очереди рассмотрим каждую из этих проблем.
Сложности с подбором подходящих сервисов
Одна из проблем, возникающих при использовании микросервисной архитекту
ры, связана с отсутствием конкретного, хорошо описанного алгоритма разбиения
системы на микросервисы. Как и многое в профессии разработчика, это сродни
искусству. Но что еще хуже, если вы неправильно разделили систему, у вас полу
чится распределенный монолит — набор связанных между собой сервисов, которые
необходимо развертывать вместе. Распределенному монолиту присущи недостатки
как монолитной, так и микросервисной архитектуры.
1.5. Достоинства и недостатки микросервисной архитектуры 45
Сложность распределенных систем
Еще один недостаток микросервисной архитектуры состоит в том, что при создании
распределенных систем возникают дополнительные сложности для разработчи
ков. Сервисы должны использовать механизм межпроцессного взаимодействия.
Это сложнее, чем вызывать обычные методы. К тому же ваш код должен уметь
справляться с частичными сбоями и быть готовым к недоступности или высокой
латентности удаленного сервиса.
Реализация сценариев, охватывающих несколько сервисов, требует применения
незнакомых технологий. Каждый сервис имеет собственную базу данных, что затруд
няет реализацию комбинированных транзакций и запросов. Как описывается в главе 4,
приложение, основанное на микросервисах, должно использовать так называемые
повествования (или саги), чтобы сохранять согласованность данных между серви
сами. В главе 7 объясняется, что такие приложения не могут извлекать данные из
нескольких сервисов с помощью простых запросов. Вместо этого их запросы должны
задействовать либо комбинированные API, либо CQRS-представления.
IDE и другие инструменты разработки рассчитаны на создание монолитных
приложений и не обеспечивают явной поддержки распределенных приложений.
Написание автоматических тестов, затрагивающих несколько сервисов, — непростая
задача. Все эти проблемы характерны для микросервисной архитектуры. Следова
тельно, для успешного применения микросервисов программисты вашей компании
должны иметь отточенные навыки разработки и доставки кода.
Вдобавок микросервисная архитектура существенно усложняет администриро
вание. В промышленной среде приходится иметь дело с множеством экземпляров
разнородных сервисов. Для успешного развертывания микросервисов требуется
высокая степень автоматизации. Вы должны использовать технологии следующего
плана:
□ инструменты для автоматического развертывания, такие как Netflix Spinnaker;
□ общедоступную платформу PaaS, такую как Pivotal Cloud Foundry или Red Hat
OpenShift;
□ платформу оркестрации для Docker, такую как Docker Swarm или Kubernetes.
Подробнее о вариантах развертывания я расскажу в главе 12.
Развертывание функций, охватывающих несколько сервисов,
требует тщательной координации
Еще одна проблема микросервисной архитектуры связана с тем, что развертывание
функций, охватывающих несколько сервисов, требует тщательной координации
действий разных команд разработки. Вам необходимо выработать план «выкаты
вания» обновлений, который запрашивает развертывание сервисов с учетом их за
висимостей. Для сравнения: в монолитной архитектуре вы можете легко выпускать
автоматические обновления для нескольких компонентов.
46 Глава 1 • Побег из монолитного ада
Нетривиальность решения о переходе
Еще одна трудность связана с решением о том, на каком этапе жизненного цикла
приложения следует переходить на микросервисную архитектуру. Часто во время
разработки первой версии вы еще не сталкиваетесь с проблемами, которые эта
архитектура решает. Более того, применение сложного, распределенного метода
проектирования замедлит разработку. Для стартапов, которым обычно важнее всего
как можно быстрее развивать свою бизнес-модель и сопутствующее приложение, это
может вылиться в непростую дилемму. Использование микросервисной архитек
туры делает выпуск начальных версий довольно трудным. Стартапам почти всегда
лучше начинать с монолитного приложения.
Однако со временем возникает другая проблема: как справиться с возраста
ющей сложностью. Это подходящий момент для того, чтобы разбить приложение
на микросервисы с разными функциями. Рефакторинг может оказаться непростым
из-за запутанных зависимостей. В главе 13 мы поговорим о стратегиях перехода
с монолитной архитектуры на микросервисную.
Как видите, несмотря на множество достоинств, микросервисы обладают и суще
ственными недостатками. В связи с этим к переходу на них следует отнестись очень
серьезно. Но обычно для сложных проектов, таких как пользовательские веб- или
SaaS-приложения, это правильный выбор. Такие общеизвестные сайты, как eBay
(www.slideshare.net/RandyShoup/the-ebay-architecture-striking-a-balance-between-site-stability-
feature-velocity-performance-and-cost), Amazon.com, Groupon и Gilt, в свое время перешли
на микросервисы с монолитной архитектуры.
При использовании микросервисов приходится иметь дело с множеством про
блем, связанных с проектированием и архитектурой. К тому же многие из этих
проблем имеют несколько решений со своими плюсами и минусами. Единого
идеального решения не существует. Чтобы помочь вам сделать выбор, я создал
язык шаблонов микросервисной архитектуры и буду ссылаться на него на страни
цах книги. Посмотрим, что представляет собой этот язык и почему он вам приго
дится.
1.6. Язык шаблонов микросервисной
архитектуры
Архитектура и проектирование в конечном итоге сводятся к принятию решений.
Вам нужно решить, какая архитектура лучше всего подходит для вашего прило
жения — монолитная или микросервисная. Делая этот выбор, вы должны взвесить
большое количество за и против. Если остановитесь на микросервисах, вам придется
столкнуться с множеством вызовов.
Язык шаблонов — хороший способ описания архитектурных и проектировоч
ных методик и помогает принять решение. Сначала поговорим о том, зачем нужны
шаблоны проектирования и соответствующий язык, а затем пройдемся по языку
шаблонов микросервисной архитектуры.
1.6. Язык шаблонов микросервисной архитектуры 47
1.6.1. Микросервисная архитектура не панацея
В 1986 году Фред Брукс (Fred Brooks), автор книги The Mythical Man-Month
(Addison-Wesley Professional, 1995)1, высказал мнение, что при разработке программ
ного обеспечения не существует универсальных решений. Это означает, что нет
таких методик или технологий, применение которых повысит вашу продуктивность
в десять раз. Тем не менее и по прошествии нескольких десятилетий программисты
продолжают рьяно спорить о своих любимых инструментах, будучи уверенными
в том, что те дают им огромное преимущество в работе.
Во многих дискуссиях такого рода мнения слишком расходятся. Для этого фе
номена даже есть специальный термин — Suck/Rock Dichotomy (http://nealford.com/
memeagora/2009/08/05/suck-rock-dichotomy.html, Нил Форд (Neal Ford)), который иллю
стрирует ситуацию, когда в мире программного обеспечения все либо очень хорошо,
либо очень плохо. Такая аргументация имеет следующий вид: если вы сделаете X,
произойдет катастрофа, поэтому вы должны сделать Y. Примером может служить
противостояние между синхронным и реактивным программированием, объектно-
ориентированным и функциональным подходами, Java и JavaScript, REST и обменом
сообщениями. В реальности, естественно, все немного сложнее. У каждой техноло
гии есть недостатки и ограничения, ее приверженцы их часто игнорируют. В итоге
переход на ту или иную технологию обычно происходит в соответствии с циклом
зрелости (https://ru.Wikipedia.org/wiki/Gartner#L(MKn_xanna), состоящим из пяти стадий,
включая пик чрезмерных ожиданий (мы нашли панацею), избавление от иллюзий
(это никуда не годится) и плато продуктивности (теперь мы понимаем все плюсы
и минусы и знаем, когда это лучше применять).
Микросервисы в этом смысле не исключение. Подходит ли данная архитектура
для вашего приложения, зависит от множества факторов. Следовательно, нельзя
воспринимать их как решение на все случаи жизни, равно как и не стоит полностью
от них отказываться. Как часто бывает, нужно учитывать конкретную ситуацию.
Основной причиной непримиримых споров о технологиях является то, что людь
ми зачастую движут эмоции. В превосходной книге The Righteous Mind: Why Good
People Are Divided by Politics and Religion (Vintage, 2013) Джонатан Хайдт (Jonathan
Haidt) описывает работу человеческого разума на примере слона и наездника. Слон
представляет собой эмоциональную составляющую нашего мозга, которая прини
мает большинство решений. Наездник представляет рациональную часть: иногда
он может повлиять на слона, но чаще всего лишь рационализирует то, что слон уже
и так сделал.
Мы, как сообщество разработчиков программного обеспечения, должны побо
роть свою эмоциональность и найти лучший способ обсуждения и применения тех
нологий. Отличным методом обсуждения и описания технологий является формат
шаблонов проектирования (ввиду своей объективности). Например, описывая с его
помощью инструмент разработки, вы должны упомянуть и о недостатках. Рассмо
трим этот формат.
Брукс Ф. Мифический человеко-месяц. — М.: Символ-Плюс, 2010.
48 Глава 1 • Побег из монолитного ада
1.6.2. Шаблоны проектирования и языки шаблонов
Шаблон проектирования — это многоразовое решение проблемы, возникающей
в определенном контексте. Это идея, которая возникает как часть реальной архитек
туры и затем показывает себя с лучшей стороны при проектировании программного
обеспечения. Концепцию шаблонов предложил Кристофер Александер (Christopher
Alexander), практикующий архитектор программных систем. Ему также принад
лежит идея языка шаблонов — набора шаблонов проектирования, которые решают
проблемы в определенной области. В его книге A Pattern Language: Towns, Buildings,
Construction (Oxford University Press, 1977) описывается язык для архитектуры,
состоящей из 253 шаблонов. Там можно найти решения и для высокоуровневых
проблем, таких как поиск места для города («доступ к воде»), и для узких задач
наподобие проектирования комнаты («освещение двух сторон каждой комнаты»).
Каждый из этих шаблонов решает проблему путем упорядочения физических объек
тов, варьирующихся от целых городов до отдельных окон.
Идеи Кристофера Александера помогли концепциям шаблонов проектирования
и языков шаблонов стать общепринятыми в среде программистов. Объектно-
ориентированные шаблоны собраны в книге Эриха Гаммы (Erich Gamma), Ричарда
Хелма (Richard Helm), Ральфа Джонсона (Ralph Johnson) и Джона Влиссиде-
са (John Vlissides) Design Patterns: Elements of Reusable Object-Oriented Software
(Addison-Wesley Professional, 1994)1. Эта книга сделала шаблоны проектирования
популярными у разработчиков. Начиная с середины 1990-х программисты задо
кументировали бесчисленное количество шаблонов. Программный шаблон решает
архитектурную проблему, определяя ряд взаимодействующих между собой про
граммных элементов.
Представьте, что вы, к примеру, создаете электронный банкинг, который дол
жен учитывать разнообразные правила превышения кредита. Каждое правило
описывает ограничение баланса на счете и комиссию, которая снимается при
исчерпании кредитных средств. Эту задачу можно решить с помощью широко
известного шаблона «Стратегия» из вышеупомянутой книги. Решение состоит
из трех частей:
□ интерфейса Overdraft, который инкапсулирует алгоритм превышения кредита;
□ одного или нескольких классов-реализаций, по одному для каждого конкретного
контекста;
□ класса Account, который использует наш алгоритм.
Шаблон проектирования «Стратегия» является объектно-ориентированным, по
этому элементы решения выполнены в виде классов. Позже в этом разделе я опишу
высокоуровневые шаблоны проектирования, в которых решения состоят из взаимо
действующих между собой сервисов.
1 Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Приемы объектно-ориентированного про
ектирования. Паттерны проектирования. — СПб.: Питер, 2016.
1.6. Язык шаблонов микросервисной архитектуры 49
Одна из причин, почему шаблоны проектирования так ценятся, — они должны
описывать контекст, в котором их можно применять. То, что решение ограничено
определенным контекстом и может плохо работать в других ситуациях, — это шаг
вперед по сравнению с тем, как обсуждение технологий происходило раньше. На
пример, решение, которое подходит в масштабах компании Netflix, может оказаться
не самым лучшим для приложения с несколькими пользователями.
Необходимость учитывать контекст далеко не единственная положительная
сторона шаблонов проектирования. Они заставляют вас описывать другие важные
аспекты решения, которые часто упускают из виду. Распространенная структура
шаблонов включает в себя три особо важных раздела:
□ причины;
□ итоговый контекст;
□ родственные шаблоны.
Рассмотрим каждый из них, начиная с причин.
Причины: аспекты решаемой вами проблемы
Раздел «причины» (forces) в шаблоне проектирования описывает различные аспекты
проблемы, которую вы решаете в заданном контексте. Они могут противоречить
друг другу, поэтому иногда проблему нельзя решить полностью. То, какие из них
более значимы, зависит от контекста, поэтому вы должны расставить приоритеты.
Например, код должен быть понятным и производительным. Код, написанный в ре
активном стиле, производительнее, чем синхронный код, но зачастую в нем сложнее
разобраться. Явное указание причин помогает определиться с тем, какие из аспектов
проблемы необходимо решить.
Итоговый контекст: последствия
применения шаблона
Раздел «итоговый контекст» (resulting context) описывает последствия применения
шаблона проектирования. Он состоит из трех частей.
□ Преимущества — преимущества шаблона, включая аспекты проблемы, которые
он решает.
□ Недостатки — недостатки шаблона, включая нерешенные аспекты проблемы.
□ Замечания — новые проблемы, появляющиеся в результате применения шаблона.
Итоговый контекст формирует более комплексное и объективное представление
о решении, что помогает сделать правильный выбор при проектировании.
Родственные шаблоны пяти типов
Раздел «родственные шаблоны» (related patterns) описывает связь между действу
ющим и другими шаблонами проектирования. Связь бывает пяти типов.
50 Глава 1 • Побег из монолитного ада
□ Предшественник — предшествующий шаблон, который обосновывает потреб
ность в данном шаблоне. Например, микросервисная архитектура — это пред
шественник всех остальных шаблонов в языке шаблонов, кроме монолитной
архитектуры.
□ Преемник — шаблон, который решает проблемы, порожденные данным шабло
ном. Например, при использовании микросервисной архитектуры необходимо
применить целый ряд шаблонов-преемников, включая обнаружение сервисов
и шаблон «Предохранитель».
□ Альтернатива — альтернативное решение по отношению к данному шаблону.
Например, монолитная и микросервисная архитектуры — это альтернативные
способы проектирования приложения. Нужно выбрать одну из них.
□ Обобщение — обобщенное решение проблемы. Например, в главе 12 представлены
разные реализации шаблона «Один сервис — один сервер».
□ Специализация — специализированная разновидность шаблона. Например, в гла
ве 12 вы узнаете, что развертывание сервиса в виде контейнера — это частный
случай шаблона «Один сервис — один сервер».
Кроме того, можно группировать шаблоны по областям, в которых их применяют.
Явное описание родственных шаблонов помогает получить представление о том, как
эффективно решить ту или иную проблему.
Пример визуального представления связей между шаблонами приведен на
рис. 1.9.
Рис. 1.9. Визуальное представление разного вида связей между шаблонами
1.6. Язык шаблонов микросервисной архитектуры 51
На рис. 1.9 представлены следующие типы связей между шаблонами:
□ шаблон-преемник решает проблему, возникшую в результате применения ша
блона-предшественника;
□ у одной и той же проблемы может быть несколько альтернативных решений;
□ один шаблон может быть специализированной версией другого;
□ шаблоны, решающие проблемы в одной и той же области, можно сгруппировать
или обобщить.
Набор шаблонов проектирования, имеющих подобные связи, иногда можно объ
единить в так называемый язык шаблонов. Шаблоны, входящие в него, совместно
работают над решением проблем в определенной области. Например, я создал язык
шаблонов микросервисной архитектуры. Это набор взаимосвязанных методик для
проектирования микросервисов. Рассмотрим его подробнее.
1.6.3. Обзор языка шаблонов
микросервисной архитектуры
Язык шаблонов микросервисной архитектуры — это набор методик, которые по
могают в проектировании приложений на основе микросервисов. Общая струк
тура этого языка показана на рис. 1.10. Прежде всего он помогает определиться
с тем, нужно ли вам использовать микросервисную архитектуру. Он описывает
оба подхода, микросервисный и монолитный, вместе с их достоинствами и недо
статками. А если микросервисная архитектура подходит для вашего приложения,
язык шаблонов поможет эффективно ее задействовать, решая различные проблемы
проектирования.
Данный язык состоит из нескольких групп шаблонов. В левой части рис. 1.10 пред
ставлена группа шаблонов архитектуры приложения, в которую входят монолитные
и микросервисные методы проектирования. Это шаблоны, которые обсуждаются
в этой главе. Остальная часть языка содержит группы шаблонов для решения проблем,
порожденных микросервисной архитектурой. Шаблоны также делятся на три уровня.
□ Инфраструктурные шаблоны — решают проблемы, в основном касающиеся ин
фраструктуры и не относящиеся к разработке.
□ Инфраструктура приложения — предназначены для инфраструктурных задач,
влияющих на разработку.
□ Шаблоны приложения — решают проблемы, с которыми сталкиваются разработ
чики.
Шаблоны группируются в зависимости от того, какого рода проблемы они реша
ют. Рассмотрим основные группы шаблонов.
52 Глава 1 • Побег из монолитного ада
Р
и
с.
1
.1
0.
О
бщ
ая
с
тр
ук
ту
ра
я
зы
ка
ш
аб
ло
но
в
м
ик
ро
се
рв
ис
но
й
ар
хи
те
кт
ур
ы
, о
пи
сы
ва
ю
щ
ая
р
аз
ны
е
пр
об
ле
м
ны
е
об
ла
ст
и,
н
а
ко
то
ры
е
ра
сс
чи
та
ны
ш
аб
ло
ны
. С
ле
ва
н
ах
од
ят
ся
м
ет
од
ик
и
пр
ое
кт
ир
ов
ан
ия
п
ри
ло
ж
ен
ия
—
м
он
ол
ит
но
го
и
м
ик
ро
се
рв
ис
но
го
. О
ст
ал
ьн
ы
е
гр
уп
пы
ш
аб
ло
но
в
ре
ш
аю
т
пр
об
ле
м
ы
, в
оз
ни
ка
ю
щ
ие
в
р
ез
ул
ьт
ат
е
вы
бо
ра
м
ик
ро
се
рв
ис
но
й
ар
хи
те
кт
ур
ы
1.6. Язык шаблонов микросервисной архитектуры 53
Шаблоны для разбиения приложения на микросервисы
Решение о том, каким образом разбить систему на набор сервисов, сродни искус
ству, но в этом вам может помочь целый ряд стратегий. Два шаблона декомпозиции
(рис. 1.11) по-разному подходят к определению архитектуры приложения.
Рис. 1.11. Существует два метода декомпозиции: по бизнес-возможностям (когда сервисы
группируются на основе бизнес-возможностей) и по проблемным областям
(согласно предметно-ориентированному проектированию)
Эти шаблоны подробно описываются в главе 2.
Шаблоны взаимодействия
Приложение, основанное на микросервисной архитектуре, является распределенной
системой. Важную роль в этой архитектуре играет межпроцессное взаимодействие
(interprocess communication, IPC). Вам придется принять ряд архитектурных реше
ний о том, как ваши сервисы будут взаимодействовать друг с другом и с внешним
миром. На рис. 1.12 показаны шаблоны взаимодействия, разделенные на пять групп.
□ Стиль взаимодействия. Какой механизм IPC следует использовать?
□ Обнаружение. Каким образом клиент сервиса узнает его IP-адрес, чтобы, напри
мер, выполнить НТТР-запрос?
□ Надежность. Как обеспечить надежное взаимодействие между сервисами с уче
том того, что некоторые из них могут быть недоступны?
□ Транзакционный обмен сообщениями. Как следует интегрировать отправку со
общений и публикацию событий с транзакциями баз данных, которые обновляют
бизнес-информацию?
□ Внешний API. Каким образом клиенты вашего приложения взаимодействуют
с сервисами?
В главе 3 рассматриваются первые четыре группы шаблонов: стиль взаимодей
ствия, обнаружение, надежность и транзакционный обмен сообщениями. В главе 8
мы остановимся на шаблонах внешнего API.
54 Глава 1 • Побег из монолитного ада
Р
и
с.
1
.1
2.
П
ят
ь
гр
уп
п
ко
м
м
ун
ик
ац
ио
нн
ы
х
ш
аб
ло
но
в
1.6. Язык шаблонов микросервисной архитектуры 55
Шаблоны согласованности данных
для реализации управления транзакциями
Как упоминалось ранее, для обеспечения слабой связанности каждый сервис должен
иметь собственную базу данных. К сожалению, такой подход чреват некоторыми
существенными проблемами. Из главы 4 вы узнаете, что традиционная методика
с использованием распределенных транзакций (2PC) не подходит для современных
приложений. Вместо этого согласованность данных следует обеспечивать с помощью
шаблона «Повествование». Шаблоны, связанные с данными, показаны на рис. 1.13.
Все эти шаблоны подробно описываются в главах 4-6.
Рис. 1.13. Поскольку каждый сервис работает с собственной БД, для согласования данных между
разными сервисами требуется шаблон «Повествование»
Шаблоны запрашивания данных в микросервисной архитектуре
Использование отдельной базы данных в каждом сервисе имеет еще один недо
статок: некоторые запросы должны объединять информацию, принадлежащую
нескольким сервисам. Данные сервиса доступны только через его API, поэтому вы
не можете применять к его БД распределенные запросы. На рис. 1.14 показаны не
сколько шаблонов, с помощью которых можно реализовать запросы.
Рис. 1.14. Поскольку каждый сервис работает с собственной БД, для извлечения данных,
разбросанных по нескольким сервисам, следует использовать один из шаблонов запросов
Иногда можно применять шаблон объединения API, который обращается к API
одного или нескольких сервисов и агрегирует результаты. В некоторых ситуациях
56 Глава 1 • Побег из монолитного ада
приходится прибегать к командным запросам с разделением ответственности
(command query responsibility segregation, CQRS), которые хранят одну или не
сколько копий данных и позволяют легко к ним обращаться.
Шаблоны развертывания сервисов
Процесс развертывания монолитного приложения не всегда прост, но он прямоли
неен в том смысле, что нужно развернуть лишь одно приложение, многочисленные
экземпляры которого будут находиться за балансировщиком нагрузки.
Приложение, основанное на микросервисах, намного сложнее. У вас могут быть
десятки и сотни сервисов, написанных на различных языках с использованием раз
ных фреймворков. И придется управлять намного большим количеством элементов.
Шаблоны развертывания показаны на рис. 1.15.
Рис. 1.15. Несколько шаблонов развертывания микросервисов. Традиционный подход
состоит в развертывании сервисов в формате упаковки определенного языка. У него есть
две современные альтернативы. Первая — развертывание сервисов в виде виртуальных машин
(ВМ) или контейнеров. Вторая — бессерверные технологии: вы просто загружаете свой код,
а бессерверная платформа его выполняет. Следует использовать автоматизированную платформу
с поддержкой самообслуживания для развертывания и администрирования сервисов
Традиционный (часто ручной) способ развертывания приложений с примене
нием форматов упаковки, характерных для определенного языка (например, WAR-
файлов), нельзя масштабировать для поддержки микросервисной архитектуры.
Вам нужна высокоавтоматизированная инфраструктура развертывания. В идеале
следует использовать платформу, которая позволяет разработчику развертывать
1.6. Язык шаблонов микросервисной архитектуры 57
и администрировать свои сервисы с помощью простого пользовательского интер
фейса — консольного или графического. Такие платформы обычно основаны на
виртуальных машинах (ВМ), контейнерах или бессерверных технологиях. Разные
варианты развертывания рассматриваются в главе 12.
Шаблоны наблюдаемости позволяют понять,
как себя ведет приложение
Ключевыми моментами администрирования приложения являются понимание того,
как оно ведет себя во время работы, и диагностика таких проблем, как неудачные за
просы и продолжительное время ожидания. Монолитные приложения не всегда легко
анализировать и диагностировать, но относительно простой и прямолинейный меха
низм обработки запросов облегчает эти задачи. Балансировщик нагрузки направляет
каждый входящий запрос к определенному экземпляру приложения, которое выпол
няет небольшое количество запросов к базе данных и возвращает ответ. Например,
если нужно понять, как был обработан тот или иной запрос, вы можете просмотреть
журнальный файл того экземпляра приложения, который этим занимался.
Анализ и диагностика проблем в микросервисной архитектуре намного сложнее.
Запрос может «прыгать» от одного сервиса к другому, прежде чем клиент получит
ответ. Таким образом, вы не можете проследить этот процесс в едином журнальном
файле. Точно так же осложняется диагностика времени ожидания, ведь корень про
блемы может находиться в разных местах.
Для проектирования наблюдаемых сервисов можно использовать следующие
шаблоны.
□ API проверки работоспособности. Создайте конечную точку, которая возвращает
данные о состоянии сервиса.
□ Агрегация журналов. Записывайте поведение сервисов и сохраняйте эти записи
на центральном сервере с поддержкой поиска и оповещений.
□ Распределенная трассировка. Назначайте каждому внешнему запросу уникаль
ный идентификатор и отслеживайте его перемещение между сервисами.
□ Отслеживание исключений. Отправляйте отчеты об исключениях соответству
ющему сервису, который их дедуплицирует, оповещает разработчиков и отсле
живает разрешение каждой исключительной ситуации.
□ Показатели приложения. Собирайте количественные и оценочные показатели
и делайте их доступными для соответствующего сервиса.
□ Ведение журнала аудита. Записывайте в журнал действия пользователей.
Эти шаблоны подробно описываются в главе 11.
Шаблоны автоматического тестирования сервисов
По сравнению с монолитными приложениями микросервисная архитектура облег
чает тестирование отдельных сервисов, так как их размер намного меньше. Но в то
же время важно убедиться в том, что отдельные сервисы хорошо работают вместе,
58 Глава 1 • Побег из монолитного ада
избегая при этом использования сложных, медленных и ненадежных сквозных
тестов. Следующие шаблоны упрощают эту задачу, позволяя тестировать сервисы
изолированно друг от друга.
□ Тестирование контрактов с расчетом на потребителя — проверка того, что сер
вис отвечает ожиданиям клиентов.
□ Тестирование контрактов на стороне потребителя — проверка того, что клиент
может взаимодействовать с сервисом.
□ Тестирование компонентов сервиса — тестирование сервиса в изоляции.
Эти шаблоны тестирования подробно описываются в главах 9 и 10.
Шаблоны для решения сквозных проблем
Микросервисная архитектура предусматривает многочисленные аспекты, которые
должны быть реализованы в каждом сервисе, включая шаблоны наблюдаемости
и обнаружения. Они также должны реализовать шаблон вынесения конфигурации
вовне, который предоставляет сервису такую информацию, как параметры подклю
чения к базе данных на этапе выполнения. Написание всех этих функций с нуля для
каждого отдельного сервиса заняло бы слишком много времени. Намного лучше
применять шаблон шасси микросервисов и строить сервисы на основе фреймворков
с поддержкой этих возможностей. Подробнее об этом — в главе 11.
Шаблоны безопасности
В микросервисной архитектуре аутентификацию пользователей обычно выполняет
API-шлюз, который затем должен передать учетную информацию, например иденти
фикатор и роли, вызываемому сервису. Часто для этого применяют токены доступа,
такие как JWT (JSON Web token). API-шлюз передает токен сервисам, которые
могут его проверить и извлечь из него информацию о пользователе. Шаблон «Токен
доступа» подробно обсуждается в главе 11.
Основное внимание в языке шаблонов микросервисной архитектуры уделяется
решению задач проектирования, что неудивительно. Очевидно, что для успешной
разработки программного обеспечения нужно выбрать подходящую архитектуру,
но это еще не все. Вы также должны учитывать такие аспекты, как процесс и орга
низация разработки и доставки.
1.7. Помимо микросервисов:
процесс и организация
Микросервисная архитектура обычно становится лучшим решением для крупных
и сложных приложений. Но создание успешного продукта требует не только выбора
подходящей архитектуры, но и правильной организации и налаживания процессов
разработки и доставки. Взаимосвязь между процессом, организацией и архитекту
рой показана на рис. 1.16.
1.7. Помимо микросервисов: процесс и организация 59
Рис. 1.16. Регулярный и оперативный выпуск обновлений для крупных и сложных приложений
требует сочетания микросервисной архитектуры с DevOps, включая непрерывные доставку,
развертывание и небольшие автономные команды
С архитектурой мы уже определились. Рассмотрим организацию и процесс.
1.7.1. Организация разработки и доставки
программного обеспечения
Успешная деятельность организации сопряжена с расширением команды разра
ботки. В чем-то это хорошо, ведь чем больше разработчиков, тем больше они могут
сделать. Но, как пишет Фред Брукс в книге «Мифический человеко-месяц», затраты
на взаимодействие в команде размера N составляют О(№). Если команда слишком
разрастается, она становится неэффективной из-за медленной коммуникации между
участниками. Представьте себе ежедневные пятиминутки, на которых присутствуют
20 человек...
Решение заключается в разбиении большой команды на несколько мелких,
численностью не более 8-12 человек. У каждой команды есть четкая бизнес-ори-
ентированная цель: разработка и по возможности администрирование одного или
нескольких сервисов, которые реализуют определенную возможность или бизнес-
функцию. Команда должна быть многопрофильной и уметь разрабатывать, тести
ровать и развертывать свои сервисы, не требуя регулярного взаимодействия или
координации с другими командами.
60 Глава 1 • Побег из монолитного ада
Несколько мелких команд выигрывают в продуктивности у одной крупной.
Как говорилось в подразделе 1.5.1, микросервисная архитектура играет ключевую
роль в возможности сделать команды автономными. Каждая из них может разраба
тывать, развертывать и масштабировать свои сервисы без согласования с другими
командами. Кроме того, вы всегда будете знать, к кому обращаться, если сервис
не соответствует оговоренному уровню услуг.
Помимо прочего, такой подход значительно повышает масштабируемость ор
ганизации. Она может расширяться, присоединяя новые команды. Если какая-то
из команд станет слишком большой, можно разбить ее и связанные с ней сервисы
на несколько частей. И поскольку команды слабо связаны между собой, вы можете
избежать проблем с коммуникацией, присущих большим организациям. В итоге
появление новых людей не скажется на продуктивности.
1.7.2. Процесс разработки и доставки
программного обеспечения
Использование микросервисной архитектуры в сочетании с каскадной моделью раз
работки напоминает езду на Ferrari, в который запряжена лошадь, — большинство
преимуществ микросервисов попросту теряется. Если вы хотите создавать прило
жения с микросервисной архитектурой, крайне важно внедрить гибкие методики
разработки и развертывания, такие как Scrum или Kanban. В дополнение к этому
следует практиковать непрерывные доставку и развертывание, которые являются
частью DevOps.
Джез Хамбл (Jez Humble) предлагает следующее определение непрерывной
доставки: «Непрерывная доставка — это возможность доставлять изменения всех
типов (включая новые возможности, обновления конфигурации, заплатки и экспери
ментальные функции) в продукт или пользователям безопасным, быстрым и устой
чивым способом» (https://continuousdelivery.com/).
Ключевая характеристика непрерывной доставки состоит в том, что программное
обеспечение всегда готово к выпуску. Это требует высокого уровня автоматизации,
в том числе автоматического тестирования. Логическим развитием является не
прерывное автоматическое развертывание готового кода в промышленной среде.
Высокопродуктивные организации, практикующие этот подход, развертывают из-
1.7. Помимо микросервисов: процесс и организация 61
менения по нескольку раз в день, сталкиваются с меньшим количеством перебоев
в промышленной среде, но если это все же произошло, в состоянии быстро восста
новить работу (puppet.com/resources/whitepaper/state-of-devops-report). Как упоминалось
в подразделе 1.5.1, микросервисная архитектура напрямую поддерживает непре
рывные доставку и развертывание.
1.7.3. Человеческий фактор при переходе
на микросервисы
При переходе на микросервисы меняются архитектура, организация труда и про
цесс разработки. Но при этом меняется и рабочая среда в коллективе, а люди, как
уже говорилось, существа эмоциональные. Если этого не учесть, их эмоции могут
сделать такой переход не самым гладким. Мэри и другие руководители столкнутся
с проблемами при изменении способа разработки в компании FTGO.
В своем бестселлере Managing Transitions (Da Capo Lifelong Books, 2017; https://
wmbridges.com/books) Уильям и Сьюзан Бриджес (William & Susan Bridges) предла
гают концепцию перехода, которая описывает эмоциональную реакцию людей на
изменения. Модель перехода состоит из трех стадий.
1. Конец, потеря, смирение. Период эмоционального сдвига и сопротивления, когда
люди сталкиваются с изменением, которое заставляет их покинуть зону ком
форта. Они часто скучают по старым подходам. Например, если разработчиков
62 Глава 1 • Побег из монолитного ада
разбить на многопрофильные команды, им будет не хватать прежних товарищей.
Точно так же отдел моделирования, отвечающий за глобальную модель данных,
придет в ужас от того, что у каждого сервиса теперь будет своя модель.
2. Нейтральная зона. Промежуточная стадия между старым и новым порядком,
в это время люди часто пребывают в замешательстве и испытывают трудности
при изучении новых методов.
3. Новое начало. Завершающая стадия, когда люди с энтузиазмом принимают новый
подход к работе и начинают ощущать его преимущества.
Книга описывает то, как лучше всего справиться с каждой стадией перехода
и повысить шансы на успех внедрения изменений. Очевидно, что компания FTGO
страдает из-за монолитного ада и нуждается в миграции на микросервисную архи
тектуру. Но это может изменить ее структуру и процесс разработки. Чтобы не по
терпеть фиаско, следует учитывать модель перехода и эмоции сотрудников.
В следующей главе мы поговорим о назначении программной архитектуры
и о том, как разбить приложение на сервисы.
Резюме
□ В соответствии с монолитной архитектурой приложение структурируется в виде
единой развертываемой сущности.
□ В микросервисной архитектуре система разбивается на независимо развертыва
емые сервисы, каждый со своей базой данных.
□ Монолитная архитектура подходит для простых приложений, а микросервисы
обычно являются лучшим решением для крупных, сложных систем.
□ Микросервисная архитектура ускоряет темп разработки программного обеспече
ния, позволяя небольшим автономным командам работать параллельно.
□ Микросервисная архитектура не панацея. У нее есть существенные недостатки,
такие как повышенная сложность.
□ Язык шаблонов микросервисной архитектуры — это набор методик, которые
облегчают проектирование приложений на основе микросервисов. Он помогает
решить, следует ли использовать микросервисную архитектуру, и, если она вам
подходит, эффективно ее применять.
□ Для ускорения доставки программного обеспечения одной микросервисной
архитектуры недостаточно. Чтобы разработка оказалась успешной, вам также
нужно задействовать DevOps и сформировать небольшие автономные команды.
□ Не забывайте о человеческом факторе перехода на микросервисы. Чтобы он стал
успешным, следует учитывать эмоциональный настрой работников.
Стратегии
декомпозиции
Иногда следует быть осторожным со своими желаниями. Усердно рекламируя микро
сервисную архитектуру, Мэри наконец-то убедила руководство в том, что миграция
на нее будет правильным решением. Одновременно воодушевленная и обеспокоен
ная, Мэри собрала архитекторов и провела с ними все утро, обсуждая, с чего лучше
начать. В ходе этого стало очевидно, что некоторые аспекты языка шаблонов микро
сервисной архитектуры, такие как развертывание и обнаружение сервисов, оказались
новыми и незнакомыми, хотя и довольно тривиальными. Основным вызовом стало
разбиение приложения на сервисы, то есть самая суть данной архитектуры. Таким обра
зом, важнейшим вопросом стало определение сервиса. Столпившись у доски для рисо
вания, члены команды FTGO пытались понять, что именно под ним подразумевается!
В этой главе вы научитесь определять микросервисную архитектуру. Я опишу
стратегии разбиения приложения на сервисы. Вы увидите, что сервисы организу
ются скорее вокруг бизнес-проблем, а не технических аспектов. Я также покажу, как
с помощью принципов предметно-ориентированного проектирования устранить так
называемые божественные классы (классы, которые используются в разных частях
приложения и создают запутанные зависимости, препятствующие декомпозиции).
64 Глава 2 • Стратегии декомпозиции
Начнем эту главу с определения микросервисной архитектуры в терминах про
ектирования программного обеспечения. После этого я опишу процесс определения
микросервисной архитектуры для приложения, начиная с его требований. Мы об
судим стратегии разбиения системы на набор сервисов, препятствия на этом пути
и то, как их преодолеть. Сначала рассмотрим концепцию архитектуры программного
обеспечения.
2.1. Что представляет собой
микросервисная архитектура
В главе 1 говорилось, что ключевой идеей микросервисной архитектуры является
функциональная декомпозиция. Вместо разработки одного большого приложения
вы создаете набор сервисов. С одной стороны, описание микросервисной архитек
туры в виде некой функциональной декомпозиции довольно практично. Но с дру
гой — это оставляет без ответа несколько вопросов. Например, какое отношение этот
подход имеет к более общему понятию архитектуры программного обеспечения?
Что такое сервис и насколько важен его размер?
Чтобы ответить на них, нужно сделать шаг назад и подумать о том, что мы пони
маем под архитектурой программного обеспечения. Архитектура приложения — это
его общая структура, состоящая из отдельных частей и зависимостей между ними.
Как вы увидите в этом разделе, данное понятие является многоуровневым и описать
его можно с разных сторон. Архитектура имеет большое значение, потому что она
определяет качественные атрибуты приложения, или его «-ости». Традиционно
архитектура сосредоточена на таких аспектах, как масштабируемость, надежность
и безопаснос77гь. Но в наши дни важным качеством является также возможность
быстрой и безопасной доставки кода. Как вы вскоре увидите, микросервисная
архитектура — это стиль проектирования, который делает приложение легко под
держиваемым, тестируемым и развертываемым.
Я начну этот раздел с описания концепции архитектуры программного обеспечения
и того, почему она так важна. Затем мы обсудим идею архитектурного стиля. В конце
я дам определение микросервисной архитектуры как стиля проектирования.
2.1.1. Что такое архитектура программного
обеспечения и почему она важна
Архитектура, несомненно, имеет значение. Этой теме посвящены как минимум
две конференции: O’Reilly Software Architecture Conference (conferences.oreilly.com/
software-architecture) и SATURN (resources.sei.cmu.edu/news-events/events/saturn/). Многие
разработчики стремятся стать архитекторами. Но что такое архитектура и почему
она важна?
Чтобы ответить на этот вопрос, сначала определимся с тем, что мы понимаем под
архитектурой программного обеспечения. Затем я покажу, что архитектура прило-
2.1. Что представляет собой микросервисная архитектура 65
жения является многомерной и что лучше всего ее описывать в виде набора пред
ставлений или блок-схем. Далее вы узнаете, что важность программной архитектуры
связана с ее влиянием на качественные атрибуты приложения.
Определение архитектуры программного обеспечения
Архитектура программного обеспечения имеет бесчисленное количество опре-
делений. Некоторые из них можно найти на странице https://en.wikiquote.org/wiki/
Software_architecture. Мое любимое определение принадлежит Лену Бассу (Len Bass)
и его коллегам по Институту программной инженерии (www.sei.cmu.edu), которые
сыграли ключевую роль в становлении этой области в качестве отдельной дисципли
ны. Они определяют архитектуру программного обеспечения следующим образом:
программная архитектура вычислительной системы — это набор структур, необхо
димых для ее обсуждения и состоящих из программных элементов, связей между ними
и свойств, присущих этим элементам и связям (Bass L. et al. Documenting Software
Architectures).
Это довольно абстрактное определение. Но его суть в том, что архитектура при
ложения — это его декомпозиция на части (элементы) и связи между ними. Деком
позиция важна по нескольким причинам.
□ Она способствует разделению труда и знаний, так как позволяет нескольким
людям или командам с потенциально узкоспециализированными знаниями про
дуктивно работать над одним приложением.
□ Она определяет то, как взаимодействуют между собой программные элементы.
Разбиение на части и отношения между этими частями определяют качественные
характеристики приложения.
Модель представлений архитектуры вида 4+1
Говоря конкретней, архитектуру приложения можно рассматривать с разных сторон,
точно так же как архитектуру здания можно оценивать с точки зрения конструкции,
водопровода, электропроводки и т. д. Филлип Кратчен (Phillip Krutchen) написал
классический документ, посвященный модели представлений программной архи
тектуры вида 4 + 1, — Architectural Blueprints — The “4 + 7” View Model of Software
Architecture (www.cs.ubc.ca/~gregor/teaching/papers/4-i-lview-architecture.pdf). Модель 4 + 1
(рис. 2.1) определяет четыре разных представления архитектуры программного обе
спечения. Каждое из них описывает определенный аспект архитектуры и состоит из
конкретного набора программных элементов и связей между ними.
Эти представления имеют следующие цели.
□ Логическое представление — программные элементы, создаваемые разработчика
ми. В объектно-ориентированных языках это классы и пакеты. Связи между ними
соответствуют отношениям между классами и пакетами, включая наследование,
взаимосвязи и зависимости.
66 Глава 2 • Стратегии декомпозиции
Рис. 2.1. Модель представлений вида 4 + 1 описывает архитектуру приложения с помощью
четырех представлений и сценариев, которые показывают, как элементы внутри каждого
представления взаимодействуют для обработки запросов
□ Представление реализации — результат работы системы сборки. Это представ
ление состоит из модулей, представляющих упакованный код, и компонентов,
которые являются исполняемыми или развертываемыми единицами и содержат
один или несколько модулей. В Java модуль имеет формат JAR, а компонентом
обычно выступает WAR- или исполняемый JAR-файл. Связь между ними опре
деляется зависимостями между модулями и тем, какие компоненты объединены
в тот или иной модуль.
□ Представление процесса — компоненты на этапе выполнения. Каждый элемент
является процессом, а отношения между процессами представляют межпроцесс
ное взаимодействие.
□ Развертывание — то, как процессы распределяются по устройствам. Элементы
в этом представлении состоят из серверов (физических или виртуальных) и про
цессов. Связи между серверами представляют сеть. Это представление также
описывает отношение между процессами и устройствами.
Вдобавок к этим четырем представлениям существуют сценарии (+ 1 в моде
ли 4 + 1), которые их оживляют. Каждый сценарий описывает, как различные архи
тектурные компоненты внутри конкретного представления взаимодействуют между
собой, чтобы обработать запрос. Например, сценарий в логическом представлении
2.1. Что представляет собой микросервисная архитектура 67
демонстрирует взаимодействие классов. Аналогично сценарий в представлении
процесса показывает совместную работу процессов.
Модель представлений вида 4 + 1 — это отличный способ описания архитектуры
приложения. Каждое представление иллюстрирует важный аспект архитектуры,
а сценарии показывают, как взаимодействуют представления. Теперь посмотрим,
почему архитектура так важна.
Почему архитектура имеет значение
Требования к приложению делятся на две категории. Первая включает в себя функ
циональные требования, определяющие назначение кода. Обычно они представле
ны в виде сценариев использования или пользовательских историй. Архитектура
не играет здесь большой роли. Функциональные требования можно реализовать
с помощью почти любой архитектуры, даже самой плохой.
Архитектура выходит на первый план, когда речь заходит о второй категории
требований, связанной с качеством обслуживания. Это так называемые качественные
атрибуты. Они определяют такие свойства времени выполнения, как масштабиру
емость и надежность. А также описывают аспекты разработки, такие как простота
в обслуживании, тестировании и развертывании. От выбранной вами архитектуры
зависит, насколько приложение отвечает этим требованиям к качеству.
2.1.2. Обзор архитектурных стилей
В реальном мире архитектура здания относится к определенному стилю: викториан
скому, ар-деко и т. д. Каждый стиль — это набор проектировочных решений, опреде
ляющих отличительные признаки здания и строительные материалы. Эту же концеп
цию можно применить к программному обеспечению. Дэвид Гарлан (David Garlan)
и Мэри Шоу (Магу Shaw) (Ап Introduction to Software Architecture, January 1994,
www.cs.cmu.edu/afs/cs/project/able/ftp/intro_softarch/intro_softarch.pdf), пионеры в области
программной архитектуры, дают архитектурному стилю следующее определение:
архитектурный стиль определяет семейство подобных систем с точки зрения
структурной организации. В частности, стиль определяет набор компонентов
и коннекторов, которые можно применять в реализациях этого стиля, а также ряд
правил, согласно которым они могут сочетаться.
Конкретный архитектурный стиль предоставляет ограниченную палитру элемен
тов (компонентов) и связей (коннекторов), на основе которых вы можете описать
представление архитектуры своего приложения. На практике обычно использу
ется сочетание архитектурных стилей. Например, позже в этой главе вы увидите,
что монолитная архитектура — это стиль, который структурирует представление
реализации в виде единого (исполняемого/развертываемого) компонента. Микро
сервисная архитектура структурирует приложение в виде набора слабо связанных
сервисов.
68 Глава 2 • Стратегии декомпозиции
Многоуровневый архитектурный стиль
Классическим примером архитектурного стиля является многоуровневая архитекту
ра. Она распределяет программные элементы по разным уровням. Каждый уровень
имеет четко обозначенный набор обязанностей. Также ограничиваются зависимости
между уровнями. Уровень может зависеть либо от уровня, находящегося непосред
ственно под ним (строгое разбиение), либо от любого нижележащего уровня.
Многоуровневую архитектуру можно применить к любому из рассмотренных
ранее представлений, трехуровневую архитектуру (ее частный случай) — к логи
ческому представлению. Она разделяет классы приложения на следующие уровни
или слои:
□ уровень представления — содержит код, реализующий пользовательский интер
фейс или внешние API;
□ уровень бизнес-логики — содержит бизнес-логику;
□ уровень хранения данных — реализует логику взаимодействия с базой данных.
Многоуровневая архитектура — отличный пример архитектурного стиля, но у нее
есть некоторые существенные недостатки.
□ Единый уровень представления — не учитывает того, что приложение, скорее
всего, будет вызываться более чем одной системой.
□ Единый уровень хранения данных — не учитывает того, что приложение, скорее
всего, будет взаимодействовать более чем с одной базой данных.
□ Уровень бизнес-логики зависит от уровня хранения данных — теоретически эта
зависимость не позволяет тестировать бизнес-логику отдельно от базы данных.
Кроме того, многоуровневая архитектура искажает зависимости в хорошо спро
ектированном приложении. Бизнес-логика обычно предусматривает интерфейс или
репозиторий интерфейсов, которые определяют методы доступа к данным. Уровень
хранения данных определяет классы DAO, которые реализуют интерфейсы репози
тория. Иными словами, зависимости являются обратными относительно того, что
описывает многоуровневая архитектура.
Рассмотрим альтернативный подход, который позволяет преодолеть эти недо
статки, — шестигранную архитектуру.
О шестигранном архитектурном стиле
Шестигранная архитектура — это альтернатива многоуровневому стилю проек
тирования. Она организует логическое представление таким образом, что бизнес-
логика оказывается в центре (рис. 2.2). Вместо уровня представления у приложения
есть один или несколько входящих адаптеров, которые обрабатывают внешние
запросы путем вызова бизнес-логики. Аналогично вместо уровня хранения данных
используются один или несколько исходящих адаптеров, которые вызываются биз-
нес-логикой и обращаются к внешним приложениям. Ключевой характеристикой
и преимуществом данной архитектуры является то, что бизнес-логика не зависит от
адаптеров. Все наоборот: адаптеры зависят от нее.
2.1. Что представляет собой микросервисная архитектура 69
Рис. 2.2. Пример шестигранной архитектуры, состоящей из бизнес-логики и одного
или нескольких адаптеров, которые взаимодействуют с внешними системами. Бизнес-логика
содержит один или несколько портов. Входящие адаптеры, обрабатывающие запросы от внешних
систем, обращаются к входящему порту. Исходящий адаптер реализует исходящий порт
и обращается к внешней системе
У бизнес-логики есть один или несколько портов. Порт определяет набор опера
ций и то, как и в чем бизнес-логика взаимодействует с внешним кодом. В Java, напри
мер, порт часто является Java-интерфейсом. Существует два вида портов: входящие
и исходящие. Входящий порт — это API, выставляемый наружу бизнес-логикой
и доступный для вызова внешними приложениями. В качестве примера входящего
порта можно привести интерфейс сервиса, который описывает его публичные мето
ды. Исходящий порт — это то, как бизнес-логика обращается к внешним системам.
Примером может служить интерфейс репозитория, определяющий набор операций
для доступа к данным.
Вокруг бизнес-логики размещаются адаптеры. Как и порты, они бывают двух
типов: входящие и исходящие. Входящий адаптер обрабатывает запросы из внеш
него мира, обращаясь к входящему порту. Примером входящего адаптера может
служить контроллер Spring MVC, который реализует либо набор конечных точек
формата REST, либо коллекцию веб-страниц. Еще один пример — клиентский брокер
70 Глава 2 • Стратегии декомпозиции
сообщений, который на них подписывается. Несколько входящих адаптеров могут
обращаться к одному и тому же входящему порту.
Исходящий адаптер реализует исходящий порт и обрабатывает запросы биз-
нес-логики, обращаясь к внешнему приложению или сервису. В качестве примера
исходящего адаптера можно привести класс объекта доступа к данным (data access
object, DAO), который реализует операции для работы с базой данных. Еще один
пример — класс прокси, вызывающий внешний сервис. Исходящие адаптеры также
могут публиковать события.
Важное преимущество шестигранного архитектурного стиля состоит в том, что
его адаптеры отделяют бизнес-логику от логики представления и доступа к данным
и делают ее независимой.
Благодаря этому изолированное тестирование бизнес-логики намного упрощает
ся. Еще одна сильная сторона этого стиля связана с тем, что он более точно отражает
архитектуру современных приложений. Бизнес-логику можно вызывать с помощью
разных адаптеров, каждый из которых реализует определенный программный или
пользовательский интерфейс. Сама бизнес-логика тоже может обратиться к одному
из нескольких адаптеров, вызывающих определенную внешнюю систему. Шести
гранный стиль отлично подходит для описания каждого сервиса в микросервисной
архитектуре.
Многоуровневая и шестигранная архитектуры — это примеры архитектурных
стилей. Каждая из них определяет составляющие приложения и ограничивает отно
шения между ними. Шестигранная и многоуровневая (в виде трехуровневой) архи
тектуры организуют логическое представление. Теперь определим микросервисы
как архитектурный стиль, который описывает представление реализации.
2.1.3. Микросервисная архитектура
как архитектурный стиль
Мы уже обсудили архитектурные стили и модель представлений вида 4+1. Теперь
можно дать определение монолитной и микросервисной архитектурам. Обе они
являются архитектурными стилями. Монолитная архитектура структурирует пред
ставление реализации в виде единого компонента — исполняемого или WAR-файла.
Это определение оставляет без внимания другие представления. Монолитное при
ложение, к примеру, может иметь логическое представление, организованное по
принципу шестигранной архитектуры.
2.1. Что представляет собой микросервисная архитектура 71
Микросервисная архитектура тоже является архитектурным стилем. Она струк
турирует представление реализации в виде набора компонентов — исполняемых или
WAR-файлов. Компоненты представлены сервисами, а в качестве коннекторов слу
жат коммуникационные протоколы, которые позволяют этим сервисам взаимодей
ствовать между собой. Каждый сервис имеет собственную архитектуру логического
представления (обычно это шестигранная архитектура). На рис. 2.3 показан пример
микросервисной архитектуры для приложения FTGO; сервисы соответствуют биз-
нес-функциям, таким как управление заказами или ресторанами.
Рис. 2.3. Потенциальная микросервисная архитектура для приложения FTGO, состоящая
из множества сервисов
Позже в этой главе я объясню, что я имею в виду под бизнес-функциями. Кон
некторы между сервисами реализуются с помощью механизма межпроцессного
72 Глава 2 • Стратегии декомпозиции
взаимодействия, такого как REST API или асинхронный обмен сообщениями.
В главе 3 мы обсудим межпроцессное взаимодействие более подробно.
Ключевое ограничение, которое накладывает микросервисная архитектура, —
слабая связанность сервисов. Следовательно, сервисы ограничены в том, как они
между собой взаимодействуют. Чтобы лучше это объяснить, попытаюсь дать опре
деление терминам «сервис» и «слабая связанность» и расскажу, почему это важно.
Что такое сервис
Сервис — это автономный, независимо развертываемый программный компонент,
который реализует определенные полезные функции. На рис. 2.4 показано внешнее
представление сервиса (в данном случае Order). У него есть API, через который
сервис предоставляет доступ к своим функциям. Существует два вида операций:
команды и запросы. API состоит из команд, запросов и событий. Команда, такая
как createOrder(), выполняет действия и обновляет данные. Запрос, такой как
findOrderById(), извлекает данные. Сервис также публикует события, например
OrderCreated, которые потребляются его клиентами.
Рис. 2.4. Сервис обладает API, который инкапсулирует реализацию. API определяет операции,
вызываемые клиентами. Существует два типа операций: команды (обновляют данные) и запросы
(извлекают данные). При изменении своих данных сервис публикует события, на которые могут
подписаться его клиенты
API сервиса инкапсулирует его внутреннюю реализацию. В отличие от монолита
этот подход не позволяет разработчику писать код, минующий API. Благодаря этому
микросервисная архитектура обеспечивает модульность приложения.
2.1. Что представляет собой микросервисная архитектура 73
Каждый микросервис обладает собственной архитектурой и иногда отдельным
стеком технологий. Но обычно сервисы имеют шестигранную архитектуру. Их API
реализуются адаптерами, которые взаимодействуют с бизнес-логикой приложения.
Бизнес-логика вызывается адаптером операций, а события, которые она генерирует,
публикуются адаптером событий.
В главе 12 при обсуждении технологий развертывания вы увидите, что пред
ставление реализации сервиса способно принимать множество форм. Компонент
может быть автономным процессом, веб-приложением/OSGI-пакетом, запущенным
в контейнере, или бессерверной облачной функцией. Однако сервис должен иметь
API и развертываться независимо — это основное требование.
Что такое слабая связанность
Важной характеристикой микросервисной архитектуры является слабое связывание
сервисов (en.wikipedia.org/wiki/Loose_coupling). Все взаимодействие с сервисом проис
ходит через API, инкапсулирующий подробности его реализации. Это позволяет
изменять внутреннее содержание сервиса, не затрагивая его клиентов. Слабо свя
занные сервисы — это ключ к улучшению скорости разработки приложений, в том
числе их поддержки и тестирования. Их намного проще изменять и тестировать,
в них проще разобраться.
Требование, согласно которому сервисы должны быть слабо связанными и взаи
модействовать только через API, исключает коммуникацию через базу данных.
Постоянные данные сервиса должны восприниматься как поля класса и оставаться
приватными. Если разработчик изменит структуру базы данных, ему не нужно будет
тратить время на согласование с коллегами, которые работают над другими сервиса
ми. Отсутствие общих таблиц БД также улучшает изоляцию на этапе выполнения.
Это, например, гарантирует, что сервису не придется ждать из-за того, что другой
сервис заблокировал базу данных. Тем не менее позже вы узнаете, что у этого под
хода есть и недостатки — например, это усложняет поддержание согласованности
данных и выполнение запросов к нескольким сервисам
Роль общих библиотек
Разработчики часто упаковывают функции в библиотеки (модули), чтобы их мож
но было использовать в нескольких приложениях без дублирования кода. В конце
концов, что бы мы сейчас делали без репозиториев Maven или прш? У вас также
может возникнуть соблазн задействовать разделяемые библиотеки в микросер
висной архитектуре. На первый взгляд это выглядит хорошим решением для того,
чтобы избежать дублирования кода. Но нужно убедиться в том, что это не приведет
к связыванию ваших сервисов.
Представьте, к примеру, что нескольким сервисам нужно обновить бизнес-объект
Order. Вы можете упаковать эту функцию в библиотеку, которую станут использо
вать разные сервисы. С одной стороны, это устраняет дублирующийся код, но с дру
гой — что произойдет, если требования изменятся и это повлияет на бизнес-объект
74 Глава 2 • Стратегии декомпозиции
Order. Вам пришлось бы одновременно пересобрать и заново развернуть все эти
сервисы. Вместо этого функции, которые с большой вероятностью в дальнейшем
будут меняться, можно оформить в виде отдельного сервиса, что намного лучше.
Старайтесь применять библиотеки для функций, изменение которых мало
вероятно. Например, в типичном приложении было бы излишним реализовывать
класс Money в каждом сервисе. Вместо этого стоит создать библиотеку, с которой
будут работать эти сервисы.
Размер сервиса обычно не имеет значения
Одна из проблем термина «микросервис» — то, что в глаза сразу бросается «микро».
Это подразумевает, что сервис должен быть очень маленьким. То же самое относится
и к другим терминам, основанным на размерах, таким как мини-сервис и наносервис.
В реальности размер не является полезной характеристикой.
Определение хорошо спроектированного сервиса лучше связать с возможностью
разрабатывать его в небольшой команде, как можно быстрее и минимально взаимо
действуя с другими командами. Теоретически команда должна отвечать только за
один сервис, чтобы тот и в самом деле был микроскопическим. Если же сервис тре
бует большой команды разработчиков или слишком много времени на тестирование,
его, как и саму команду, имеет смысл разделить на части. Если же вам постоянно
приходится менять свой сервис из-за изменений в других сервисах, это признак не
достаточно слабой связанности. Возможно, у вас даже получился распределенный
монолит.
Микросервисная архитектура структурирует приложение в виде небольших
слабо связанных сервисов. Это улучшает временные показатели разработки (под-
держиваемость, тестируемость, развертываемость и т. д.) и позволяет организации
создавать лучшее программное обеспечение в более короткие сроки. А еще поло
жительно сказывается на масштабируемости, хотя это и не главная цель. Чтобы
разработать микросервисную архитектуру для своего приложения, вы должны обо
значить его сервисы и определить, как они будут взаимодействовать. Посмотрим,
как это делается.
2.2. Определение микросервисной
архитектуры приложения
Как определить микросервисную архитектуру? Как и для любого другого аспекта
разработки, все начинается с формализованных требований. При этом желательно
иметь специалистов в данной проблемной области и, возможно, существующее
приложение. В сфере программного обеспечения определение архитектуры часто
ближе к искусству, чем к науке. В данном разделе этот процесс описан в виде трех
шагов (рис. 2.5). Однако необходимо помнить, что это вовсе не инструкция, которую
следует выполнять буквально. Решить эту задачу, скорее всего, можно будет посте
пенно, подключив находчивость.
2.2. Определение микросервисной архитектуры приложения 75
Рис. 2.5. Трехшаговый процесс описания микросервисной архитектуры приложения
Приложение существует, чтобы обрабатывать запросы. Поэтому первым шагом
в определении архитектуры станет формирование ключевых запросов на основе
требований к приложению. Но вместо того, чтобы описывать запросы в виде кон
кретных технологий межпроцессного взаимодействия, я использую более абстракт
ное понятие системной операции. Системная операция представляет собой запрос,
который приложение должно обработать. Поведение каждой команды определяется
в виде абстрактной доменной модели, которая тоже извлекается из требований.
Системные операции становятся архитектурными сценариями, иллюстрирующими
взаимодействие сервисов.
76 Глава 2 • Стратегии декомпозиции
Вторым шагом в этом процессе будет разбиение на сервисы. В вашем распоря
жении несколько стратегий. Одна из них берет начало в сфере бизнес-архитектуры
и заключается в том, что сервисы должны соответствовать бизнес-функциям. Другая
предусматривает организацию сервисов вокруг подобластей в контексте предмет
но-ориентированного проектирования. В итоге сервисы будут основаны на бизнес-
концепциях, а не на технических аспектах.
Третий шаг в определении архитектуры приложения заключается в описании
API для каждого сервиса. Для этого сервисам назначаются все системные операции,
определенные на первом шаге. Операцию можно реализовать в виде одного или
нескольких сервисов. В последнем случае нужно решить, как они будут взаимодей
ствовать между собой, что обычно требует поддержки дополнительных операций
с их стороны. Вам также нужно выбрать один из механизмов IPC, описываемых
в главе 3, чтобы реализовать API каждого из сервисов.
Декомпозиция связана с несколькими трудностями. Во-первых, возникают
сетевые задержки. Вы можете обнаружить, что определенная схема разбиения не
практична из-за слишком частого обмена данными между сервисами. Во-вторых,
синхронное взаимодействие снижает доступность. Вам, возможно, придется при
бегнуть к концепции автономных сервисов, которая описывается в главе 3. Третьей
проблемой становится необходимость поддержания согласованности данных между
сервисами. Для этого обычно используются повествования, рассматриваемые в гла
ве 4. Последнее препятствие на пути к декомпозиции связано с так называемыми
божественными классами, применяемыми в разных частях приложения. К счастью,
для их устранения можно воспользоваться концепциями из предметно-ориентиро-
ванного проектирования.
Вначале в этом разделе описывается, как определить операции приложения.
После этого рассмотрим стратегии и методические рекомендации по разбиению
приложения на сервисы, а также трудности, которые при этом могут возникнуть,
и способы их преодоления. В конце я покажу, как описать API для каждого сервиса.
2.2.1. Определение системных операций
Первый шаг при проектировании архитектуры приложения — определение систем
ных операций. За отправную точку берутся требования к приложению, включая
пользовательские истории и связанные с ними сценарии использования (стоит от
метить, что они отличаются от архитектурных сценариев). Процесс идентификации
и определения системных операций состоит из двух шагов (рис. 2.6). Он навеян про
цессом объектно-ориентированного программирования, описанным в книге Крейга
Лармана (Craig Larman) Applying UML and Patterns (Prentice Hall, 2004)1 (см. по
дробности на www.craiglarman.com/wiki/index.php?title=Book_Applying_UML_and_Patterns).
На первом шаге создается обобщенная доменная модель, состоящая из ключевых
1 Ларман К. Применение UML 2.0 и шаблонов проектирования. — М.: Вильямс, 2016.
2.2. Определение микросервисной архитектуры приложения 77
классов, которые предоставляют словарь для описания системных операций. Сами
системные операции, а также их поведение с точки зрения доменной модели опи
сываются на втором шаге.
Рис. 2.6. Системные операции определяются на основе требований к приложению в ходе
двухшагового процесса. На первом шаге создается обобщенная доменная модель. На втором шаге
в рамках этой модели определяются системные операции
Доменная модель составляется в основном из имен существительных, взятых из
пользовательских историй, а системные операции — в основном из глаголов. Домен
ную модель можно определить также с помощью методики «Событийный штурм»,
о которой поговорим в главе 5. Поведение каждой системной операции описывается
с точки зрения ее влияния на один или несколько доменных объектов и отношений
между ними. Системная операция может создавать, обновлять или удалять домен
ные объекты, а также устанавливать или разрушать их связи.
Рассмотрим процесс определения обобщенной доменной модели. Затем на ее
основе я опишу системные операции.
Создание обобщенной доменной модели
Первый шаг на пути определения системных операций — очерчивание обобщенной
доменной модели приложения. Имейте в виду, что эта модель намного проще того,
что будет реализовано в итоге. У нашего приложения даже не будет единой доменной
модели, поскольку, как вы вскоре узнаете, каждый сервис имеет свою собственную.
Несмотря на чрезмерную упрощенность, на этой стадии обобщенная доменная мо
дель будет полезна, ведь она определяет словарь для описания поведения системных
операций.
Доменная модель создается с помощью стандартных методик, таких как анализ
имен существительных в пользовательских историях и консультация с экспертами
78 Глава 2 • Стратегии декомпозиции
в данной проблемной области. Возьмем, к примеру, историю размещения заказа.
Мы можем развернуть ее во множество сценариев использования, включая следующий:
Дано: клиент
И ресторан
И адрес/время доставки из этого ресторана
И суммарная цена заказа, отвечающая среднему показателю ресторана
Когда клиент размещает заказ
Тогда банковская карта клиента авторизуется
И создается заказ в состоянии PENDING-ACCEPTANCE
И заказ привязывается к клиенту
И заказ привязывается к ресторану
Имена существительные в этом пользовательском сценарии указывают на суще
ствование различных классов, включая Consumer (клиент), Order (заказ), Restaurant
(ресторан) и Creditcard (банковская карта).
Точно так же историю приема заказа можно развернуть в сценарий наподобие
следующего:
Дано: заказ в состоянии PENDING-ACCEPTANCE
И курьер, доступный для доставки заказа
Когда ресторан принимает заказ с обязательством приготовить еду к заданному времени
Тогда состояние заказа меняется на ACCEPTED
И полю заказа promiseByTime назначается заданное время
И курьер назначается для доставки заказа
Этот сценарий подразумевает существование классов Courier (курьер) и Delivery
(доставка). После нескольких этапов анализа итоговым результатом будет до
менная модель, состоящая, как и ожидалось, из этих и других классов, таких как
Menuitem (пункт меню) и Address (адрес). Схема с ключевыми классами показана
на рис. 2.7.
Рис. 2.7. Ключевые классы доменной модели FTGO
2.2. Определение микросервисной архитектуры приложения 79
Классы имеют следующие обязанности:
□ Consumer — клиент, размещающий заказ;
□ Order — заказ, размещенный клиентом;
□ OrderLineltem — отдельная позиция в заказе;
□ Delivery Inf о — время и место доставки заказа;
□ Restaurant — ресторан, готовящий заказы для доставки клиентам;
□ Menuitem — пункт в меню ресторана;
□ Courier — курьер, который доставляет клиентам заказы (отслеживает доступ
ность курьеров и их местоположение);
□ Address — адреса клиента и ресторана;
□ Location — широта и долгота местонахождения курьера.
Схема классов, приведенная на рис. 2.7, иллюстрирует один из аспектов архи
тектуры приложения. Но без сценариев, которые ее оживляют, это не более чем
красивая картинка. Следующим шагом будет определение системных операций,
соответствующих архитектурным сценариям.
Определение системных операций
Следующий шаг после описания обобщенной доменной модели — определение
запросов, которые приложение должно обрабатывать. Детали пользовательского
интерфейса выходят за рамки этой книги, но, как вы можете представить, в каждом
сценарии использования графический интерфейс извлекает и обновляет данные
путем выполнения запросов к серверной бизнес-логике. FTGO в основном пред
ставляет собой веб-приложение. Это означает, что большинство запросов основаны
на HTTP, хотя вполне вероятно, что некоторые клиенты будут применять механизм
обмена сообщениями. Таким образом, вместо привязки к конкретному протоколу
для представления запросов лучше использовать более абстрактное понятие си
стемной операции.
Системные операции бывают двух видов:
□ команды — системные операции для создания, обновления и удаления данных;
□ запросы — системные операции для чтения (запрашивания) данных.
Хорошей отправной точкой для определения системных команд будет анализ
глаголов в пользовательских историях и сценариях. Возьмем, к примеру, историю
размещения заказа. Она явно указывает на то, что система должна предоставлять
операцию Create Order (создать заказ). Многие другие истории напрямую соответ
ствуют отдельным системным командам. Некоторые ключевые системные команды
перечислены в табл. 2.1.
80 Глава 2 • Стратегии декомпозиции
Таблица 2.1. Ключевые системные команды для приложения FTGO
Действующее лицо История Команда Описание
Клиент Create Order create Order() Создает заказ
Ресторан Accept Order accept Order() Указывает на то, что ресторан
принял заказ и обязуется
приготовить его к заданному
времени
Order Ready
for Pickup
noteOrderReadyForPickup() Указывает на то, что заказ
готов к доставке
Курьер Update
Location
noteUpdatedLocation() Обновляет текущее
местоположение курьера
Delivery
picked up
noteDeliveryPickedUp() Указывает на то, что курьер
взял заказ
Delivery
delivered
noteDeliveryDelivered() Указывает на то, что курьер
доставил заказ
У команды есть спецификация, которая определяет ее параметры, возвращаемое
значение и поведение в рамках классов доменной модели. Описание поведения со
стоит из условий двух видов: предварительных и окончательных. Первые должны
выполняться в момент вызова операции, а вторые — после. Далее показан пример
спецификации для системной операции createOrder().
Операция createOrder (ID клиента, способ оплаты, адрес доставки, время
доставки, ID ресторана, позиции заказа)
Возвращает orderld...
Предварительные
условия
Клиент существует и может размещать заказы.
Позиции заказа соответствуют пунктам меню ресторана.
Адрес и время доставки выполнимы для ресторана
Окончательные
условия
Банковская карта клиента позволила снять сумму заказа.
Заказ был создан в состоянии PENDING ACCEPTANCE
Предварительные условия отражают участок «Дано» в сценарии размещения
заказа, приведенном ранее. Окончательные условия отражают участок «Тогда».
При вызове системная операция проверяет предварительные условия и производит
действия, необходимые для выполнения окончательных условий.
Далее показана спецификация системной операции acceptOrder().
Операция acceptOrder (restaurantld, orderld, readyByTime)
Возвращает -
Предварительные условия order.status равно PENDING ACCEPTANCE.
Курьер доступен для доставки заказа
Окончательные условия Состояние order.status поменялось на ACCEPTED.
Время order.readyByTime поменялось на readyByTime.
Курьер назначен для доставки заказа
2.2. Определение микросервисной архитектуры приложения 81
Ее предварительные и окончательные условия отражают сценарий использова
ния, приведенный ранее.
Большинство операций, важных с архитектурной точки зрения, являются коман
дами. Запросы, извлекающие данные, тоже иногда имеют значение.
Помимо команд, приложение должно реализовать и запросы. Они наполняют
пользовательский интерфейс информацией, необходимой для принятия решений.
На этом этапе мы еще не придумали, каким будет пользовательский интерфейс
приложения FTGO, но взгляните, к примеру, на процесс размещения заказа кли
ентом.
□ Пользователь вводит адрес и время доставки.
□ Система выводит доступные рестораны.
□ Пользователь выбирает ресторан.
□ Система выводит меню.
□ Пользователь выбирает пункт меню и оплачивает счет.
□ Система создает заказ.
Сценарий использования подразумевает следующие запросы.
□ findAvailableRestaurants(deliveryAddress, deliveryTime) — извлекает ресто
раны, которые могут выполнить доставку по заданному адресу в заданное время.
□ findRestaurantMenu(id) — извлекает информацию о ресторане, включая блюда
в меню.
Из этих двух запросов findAvailableRestaurants(), наверное, имеет наибольшее
архитектурное значение и применяет поиск по местности. Поисковая составляющая
запроса возвращает все точки (рестораны), находящиеся неподалеку от адреса до
ставки. Она также отфильтровывает все рестораны, которые будут закрыты в период
подготовки и отправки заказа. Здесь крайне важна производительность, так как этот
запрос выполняется при размещении каждого заказа.
Обобщенная доменная модель и системные операции описывают работу приложе
ния и помогают определить его архитектуру. Поведение каждой системной операции
описывается в рамках доменной модели. Каждая важная системная операция соот
ветствует сценарию, который является важной частью описания архитектуры.
Следующим шагом после определения системных операций будет обозначение
сервисов приложения. Как упоминалось ранее, для этого не предусмотрено четких
инструкций, которым просто нужно следовать. Однако мы можем воспользоваться
различными стратегиями декомпозиции, каждая из которых подходит к проблеме
с определенной стороны и использует собственную терминологию. Но какую бы
стратегию вы ни выбрали, результат будет один: архитектура, состоящая из серви
сов, которые в основном организованы вокруг бизнес-аспектов, а не технических
концепций.
Рассмотрим первую стратегию, которая описывает сервисы в соответствии с биз-
нес-возможностями.
82 Глава 2 • Стратегии декомпозиции
2.2.2. Разбиение на сервисы по бизнес-возможностям
Одной из стратегий создания микросервисной архитектуры является разбиение
по бизнес-возможностям. Концепция «бизнес-возможности» применяется в моде
лировании бизнес-архитектур и обозначает то, из чего бизнес генерирует прибыль.
Набор возможностей для конкретной компании зависит от того, чем именно она
занимается. Например, в число возможностей страховой компании обычно входят
андеррайтинг1, обработка претензий, биллинг, выполнение правовых норм и т. д.
Возможности интернет-магазина включают управление заказами, инвентаризацию,
отправку товара и пр.
Бизнес-возможности определяют то, чем занимается организация
Бизнес-возможности организации описывают то, чем она является. Обычно они
стабильны, в отличие от того, как организация ведет свой бизнес (этот аспект со
временем меняется, иногда до неузнаваемости). Это особенно актуально в наши
дни, когда технологии все чаще используются для автоматизации бизнес-процес-
сов. Например, еще совсем недавно для того, чтобы положить сумму с чека на счет,
нужно было передать его кассиру в банке. Затем стало возможно сделать это через
банкоматы, теперь же в большинстве случаев — с помощью смартфона. Как видите,
бизнес-возможность «депонирования чека» осталась неизменной, но то, каким спо
собом это делается, изменилось кардинально.
Определение бизнес-возможностей
Бизнес-возможности организации определяются путем анализа ее целей, структуры
и бизнес-процессов. Каждую возможность можно представить в виде сервиса, но для
этого она должна ориентироваться на бизнес, а не на технические аспекты. Ее спе
цификация состоит из различных компонентов, включая ввод, вывод и соглашения
уровня сервиса. Например, в случае со страховым андеррайтингом вводом является
заявление клиента, а вывод будет включать одобрение и цену.
Бизнес-возможность часто сосредоточена на определенном бизнес-объекте.
Например, бизнес-объект «претензия» лежит в основе возможности «обработка
претензий». Во многих случаях возможность можно разбить на подвозможности.
Услуги, предоставляемые финансовыми учреждениями, такими как банки, страховые
компании, которые гарантируют получение выплат в случае финансовых убытков (https: //
ni.wikipedia.org/wiki/Андеррайтинг). — Примеч. ред.
2.2. Определение микросервисной архитектуры приложения 83
Например, обработка претензий состоит из обработки информации о претензии, ее
рассмотрения и управления соответствующими выплатами.
Несложно себе представить бизнес-возможности приложения FTGO.
□ Управление поставщиками:
• управление курьерами — управление информацией о курьерах;
• управление информацией о ресторанах — управление меню ресторана и дру
гими данными, включая местоположение и график работы.
□ Управление клиентами — управление информацией о клиентах.
□ Прием и выполнение заказов:
• управление заказами — создание заказов и управление ими со стороны клиентов;
• управление заказами в ресторане — управление подготовкой заказов в ресто
ране;
• логистика;
• управление доступностью курьеров — управление готовностью курьеров до
ставить заказы в режиме реального времени;
• управление доставкой — доставка заказов клиентам.
□ Бухучет:
• отчетность по клиентам — управление клиентскими платежами;
• отчетность по ресторанам — управление платежами, поступающими в ре
стораны;
• отчетность по курьерам — управление платой курьерам.
И так далее.
Возможности верхнего уровня включают в себя управление поставщиками,
управление клиентами, принятие и выполнение заказов, бухучет. Скорее всего, этот
список будет расширен за счет других возможностей, например связанных с марке
тингом. Большинство из них разбиты на подвозможности. Например, пункт «Прием
и выполнение заказов» состоит из пяти подпунктов.
Интересная особенность этой иерархии — наличие трех возможностей, относя
щихся к ресторанам: управление информацией о ресторанах, управление заказами
в ресторане и отчетность по ресторанам. Это связано с тем, что данные возможности
представляют собой три совершенно разных аспекта работы ресторанов.
Далее вы увидите, как с помощью бизнес-возможностей описать сервисы.
От бизнес-возможностей к сервисам
Определившись с бизнес-возможностями, вы должны описать сервисы для каждой
из них или для групп связанных между собой возможностей. Схема соответствия
между возможностями и сервисами приложения FTGO показана на рис. 2.8. Иногда
сервисы создаются для возможностей верхнего уровня, таких как бухучет, а ино
гда — для подвозможностей.
84 Глава 2 • Стратегии декомпозиции
Рис. 2.8. Связывание бизнес-возможностей FTGO с сервисами. Сервисам соответствуют
возможности разных уровней иерархии
Решение о том, для возможностей какого уровня следует создавать сервисы, от
части субъективно. Мое обоснование в этом конкретном случае выглядит так.
□ Подвозможности управления поставщиками я связал с двумя сервисами, по
скольку рестораны и курьеры — это совершенно разные виды поставщиков.
□ Возможность приема и выполнения заказа соответствует трем сервисам, каждый
из которых отвечает за отдельные стадии процесса. Я объединил возможности
управления доступностью курьеров и доставкой и связал их с единым сервисом,
так как они тесно переплетаются.
□ Я выделил отдельный сервис для бухучета, поскольку разные виды отчетности
выглядят похожими.
Позже, возможно, будет разумным разделить платежи (для ресторанов и курье
ров) и биллинг (для клиентов).
2.2. Определение микросервисной архитектуры приложения 85
Ключевое преимущество организации сервисов вокруг возможностей состоит
в том, что из-за их стабильности итоговая архитектура тоже получается относитель
но стабильной. Отдельные компоненты могут эволюционировать вместе с подходами
к ведению бизнеса, но сама архитектура останется неизменной.
Тем не менее имейте в виду, что сервисы, показанные на рис. 2.8, — лишь пер
вая попытка описания архитектуры. Они могут меняться по мере более тесного
знакомства с проблемной областью приложения. В частности, важный этап про
цесса проектирования связан с исследованием того, как взаимодействуют между
собой ключевые сервисы. Например, вы можете обнаружить, что из-за излишнего
межпроцессного взаимодействия какое-то разбиение оказывается неэффективным
и некоторые сервисы лучше объединить. В то же время сервис может становиться все
сложнее до тех пор, пока его разделение на части не станет оправданным. Кроме того,
в подразделе 2.2.5 я перечислю несколько трудностей, связанных с декомпозицией,
которые могут заставить вас пересмотреть свое решение.
Рассмотрим другой способ разбиения приложения, основанный на предметно
ориентированном проектировании.
2.2.3. Разбиение на сервисы по проблемным областям
Согласно описанию, данному Эриком Эвансом (Eric Evans) в книге Domain-driven
design (Addison-Wesley Professional, 2003)1, предметно-ориентированное проектиро
вание (domain-driven design, DDD) — это способ построения сложных приложений,
основанный на разработке объектно-ориентированной доменной (проблемной)
модели. Доменная модель организует информацию о проблемной области в формате,
который можно применять для решения проблем в этой области. Она определяет
терминологию, используемую внутри команды, — так называемый язык описания.
Доменная модель находит свое воплощение в проектировании и реализации при
ложения. DDD предлагает две концепции, чрезвычайно полезные с точки зрения
микросервисной архитектуры: поддомены и изолированные контексты.
DDD довольно сильно отличается от традиционного подхода к промышленному
моделированию, в котором для целого предприятия создается единая модель. Такая
модель, к примеру, содержала бы определение для каждого бизнес-объекта, такого
как клиент, заказ и т. д. Проблема данной методики связана с тем, что создание
общей модели, на которую согласны все подразделения организации, — это колос
сальная задача. К тому же с точки зрения отдельных подразделений такая модель
1 Эванс Э. Предметно-ориентированное проектирование (DDD). — М.: Вильямс, 2010.
86 Глава 2 • Стратегии декомпозиции
будет слишком сложной для их конкретных нужд. Доменная модель может выгля
деть запутанной, так как разные части организации могут использовать один термин
для разных концепций или разные термины для одной и той же концепции. DDD
позволяет избежать всех этих проблем за счет определения нескольких доменных
моделей, каждая из которых имеет четкую область применения.
DDD определяет отдельную доменную модель для каждого поддомена. Поддомен
является частью домена, то есть проблемной области приложения в терминологии
DDD. Разбиение на поддомены происходит по тому же принципу, что и определение
бизнес-возможностей: путем анализа работы бизнеса и определения разных областей
знаний. Итоговые поддомены, скорее всего, будут похожи на бизнес-возможности.
Примерами поддоменов в контексте FTGO являются принятие заказов, управление
заказами, управление кухней, доставка и финансовая отчетность. Как видите, они
очень похожи на бизнес-возможности, описанные ранее.
В DDD область применения доменной модели называется изолированным кон
текстом. Изолированный контекст включает в себя код, который реализует модель.
При использовании микросервисной архитектуры изолированный контекст соответ
ствует одному или нескольким сервисам. Мы можем создать микросервисную архи
тектуру, задействуя DDD и определяя сервисы для каждого поддомена. На рис. 2.9
показано, как поддомены привязываются к сервисам, каждый из которых имеет
собственную доменную модель.
Рис. 2.9. От поддоменов к сервисам: каждый поддомен в домене приложения FTGO соответствует
сервису с собственной доменной моделью
2.2. Определение микросервисной архитектуры приложения 87
DDD почти идеально сочетается с микросервисной архитектурой. Концепции
поддоменов и изолированных контекстов, применяемые в DDD, прекрасно соот
носятся с микросервисами. К тому же концепция автономных команд, отвечающих
за отдельные сервисы, полностью соответствует аналогичному принципу в DDD,
согласно которому доменная модель отводится одной команде, которая ее разра
батывает. Но и это еще не все. Как вы увидите позже в этом разделе, поддомены
с отдельными доменными моделями отлично борются с божественными классами
и тем самым упрощают декомпозицию.
Разбиение по поддоменам и бизнес-возможностям — два основных подхода
к описанию микросервисной архитектуры приложения. Однако некоторые полезные
рекомендации относительно декомпозиции берут свое начало в объектно-ориенти
рованном проектировании. Давайте их рассмотрим.
2.2.4. Методические рекомендации по декомпозиции
Мы уже обсудили в этой главе основные подходы к определению микросервисной
архитектуры. Но при работе с микросервисами могут пригодиться и некоторые прин
ципы, позаимствованные из объектно-ориентированного проектирования. Они соз
даны Робертом Мартином (Robert С. Martin) и описаны в его классической книге
Designing Object Oriented C+ + Applications Using The Booch Method (Prentice Hall,
1995). Во-первых, это принцип единственной ответственности (Single Responsibility
Principle, SRP) для определения обязанностей класса. Во-вторых, принцип согла
сованного изменения (Common Closure Principle, ССР) для организации классов
в виде пакетов. Рассмотрим каждый из них и попробуем понять, как их можно при
менить к микросервисной архитектуре.
Принцип единственной ответственности
Одной из основных задач проектирования программного обеспечения является
определение обязанностей каждого программного элемента. Принцип единственной
ответственности гласит следующее: у класса должна быть только одна причина для
изменения (Роберт Мартин).
Каждая обязанность класса может нести в себе причину для его изменения.
Если у класса несколько обязанностей, которые меняются независимо друг от друга, он
не сможет оставаться стабильным. Согласно принципу SRP каждый класс должен иметь
ровно одну обязанность и, следовательно, единственную причину для изменения.
Если применить SRP к микросервисной архитектуре, каждый сервис получится
небольшим, согласованным и обладающим одной обязанностью. Это уменьшит раз
мер сервисов и повысит их стабильность. Новая архитектура FTGO — живой пример
применения SRP. Каждый аспект получения еды клиентом (принятие заказа, при
готовление блюда и доставка) — это обязанность отдельного сервиса.
Принцип согласованного изменения
Еще одной полезной идеей является принцип согласованного изменения, гласящий:
причины изменения классов, входящих в один пакет, должны быть одинаковыми.
Изменение пакета должно затрагивать все его классы (Роберт Мартин).
88 Глава 2 • Стратегии декомпозиции
Если два класса изменяются вместе по одной и той же причине, они должны
входить в один пакет. Они могут, например, реализовывать разные аспекты опре
деленного бизнес-правила. Суть в том, что, когда это бизнес-правило изменится,
разработчикам нужно будет поменять код лишь в нескольких пакетах (в идеале
только в одном). Соблюдение принципа ССР значительно упрощает поддержку
приложения.
ССР можно применить при создании микросервисной архитектуры, объединяя
компоненты, изменяющиеся по одной и той же причине, в единый сервис. Это по
зволит минимизировать количество сервисов, которые придется редактировать
и заново развертывать при изменении какого-нибудь требования. В идеале такое
изменение должно затрагивать лишь одну команду и один сервис. ССР — противо
ядие от антишаблона распределенного монолита.
SRP и ССР — это лишь два из И принципов, выработанных Бобом Мартином.
Они особенно полезны при разработке микросервисной архитектуры. Остальные
девять принципов используются при создании классов и пакетов. Больше инфор
мации об SRP, ССР и других аспектах объектно-ориентированного проектирования
можно найти в статье The Principles of Object Oriented Design на сайте Боба Мартина
(butunclebob.com/ArticleS.UncleBob).
Декомпозиция по бизнес-возможностям и поддоменам в сочетании с SRP и ССР
хорошо подходит для разбиения приложения на сервисы. Но, чтобы применить
эти методики и успешно разработать микросервисную архитектуру, вам придет
ся решить некоторые проблемы с управлением транзакциями и межпроцессным
взаимодействием.
2.2.5. Трудности при разбиении приложения
на сервисы
На первый взгляд стратегия создания микросервисной архитектуры путем описания
сервисов на основе бизнес-возможностей или поддоменов выглядит довольно про
стой. Тем не менее вы можете столкнуться со следующими проблемами:
□ латентность сети;
□ ухудшение доступности из-за синхронного взаимодействия;
□ поддержание согласованности данных между сервисами;
□ получение согласованного представления данных;
□ божественные классы, препятствующие декомпозиции.
Пройдемся по всем этим проблемам, начиная с латентности сети.
Латентность сети
Латентность сети является вечной проблемой распределенных систем. Вы мо
жете обнаружить, что определенный вид декомпозиции вынуждает сервисы часто
обмениваться данными. Иногда латентность можно снизить до приемлемого уров-
2.2. Определение микросервисной архитектуры приложения 89
ня, реализовав пакетный API для извлечения нескольких объектов за один вызов.
Но бывают ситуации, когда приходится объединять разные сервисы, отказываясь
от IPC в пользу методов или функций уровня языка.
Синхронное межпроцессное взаимодействие ухудшает доступность
Еще одна проблема связана с необходимостью реализовать межсервисное взаимо
действие таким образом, чтобы оно не сказывалось на доступности. Например, чтобы
выполнить операцию createOrder(), проще всего сделать синхронный вызов из
сервиса Order, используя REST. Недостатком таких протоколов, как REST, в данном
случае является ухудшение доступности сервиса Order: он не сможет создать заказ,
если любой из сервисов, к которым он обращается, окажется недоступным. Иногда
это можно считать разумным компромиссом, но в главе 3 вы узнаете, что зачастую
лучше применить асинхронный обмен сообщениями, который устраняет жесткую
связанность и улучшает доступность.
Обеспечение согласованности данных между сервисами
Еще одним вызовом является поддержание согласованности данных между сервиса
ми. Некоторым системным операциям нужно обновлять информацию в нескольких
сервисах. Например, когда ресторан принимает заказ, обновления должны произойти
в сервисах Kitchen и Delivery: первый меняет состояние объекта Ticket, а второй
планирует доставку заказа. Эти обновления должны выполняться автоматически.
Традиционное решение этой задачи заключается в использовании двухэтапного
механизма управления распределенными транзакциями, основанного на фиксации.
Но, как вы убедитесь в главе 4, это не лучший выбор для современных приложений
и для управления транзакциями лучше применять совсем другой подход — шаблон
«Повествование». Повествование — это последовательность локальных транзакций,
которые координируются путем обмена сообщениями. Повествования сложнее
традиционных ACID-транзакций, но они хорошо подходят для многих ситуаций.
У них есть одно ограничение — отложенная согласованность (eventual consistency).
Если вам нужно, чтобы данные обновлялись автоматически, они должны находиться
в пределах одного сервиса, что может помешать декомпозиции.
Получение согласованного представления данных
Еще одной трудностью на пути к декомпозиции является невозможность получения
по-настоящему согласованного представления данных, расположенных в разных БД.
В монолитных приложениях ACID-транзакции благодаря своим свойствам гаранти
руют, что запрос вернет согласованное представление базы данных. Для сравнения:
микросервисная архитектура не позволяет получить глобально согласованное пред
ставление данных, несмотря на то что БД каждого отдельного сервиса согласована.
Если вам нужно согласованное представление какой-то информации, она должна
находиться в одном сервисе, что может нарушить декомпозицию. К счастью, в ре
альных условиях такая проблема возникает редко.
90 Глава 2 • Стратегии декомпозиции
Божественные классы препятствуют декомпозиции
Еще одна трудность при композиции связана с существованием так называемых
божественных классов. Божественными называют раздутые классы, которые
используются в разных частях приложения (http://wiki.c2.com/7GodClass). Обычно
они реализуют бизнес-логику для разных аспектов системы и содержат большое
количество полей, привязанных к таблицам базы данных с множеством столбцов.
У большинства приложений есть по меньше мере один такой класс, представляющий
центральную концепцию той или иной проблемной области: счета в банковской
сфере, заказы в электронной торговле, полисы в страховании и т. д. Поскольку
божественный класс вмещает в себе состояние и поведение множества разных
аспектов приложения, он исключает разбиение на сервисы любой бизнес-логики,
которая его применяет.
Отличный пример божественного класса в проекте FTGO — Order. Это и неуди
вительно — в конце концов, это приложение предназначено для доставки еды клиен
там. Большинство участков системы связаны с заказами. Если бы мы использовали
единую доменную модель, класс Order имел бы огромный размер. Его состояние
и поведение пронизывали бы разные части кода. На рис. 2.10 показана структура
класса, который получился бы при традиционных методах моделирования.
Рис. 2.10. Божественный класс Order слишком раздут
и имеет многочисленные обязанности
2.2. Определение микросервисной архитектуры приложения 91
Как видите, поля и методы класса Order связаны с обработкой заказов, управле
нием заказами в ресторанах, доставкой и платежами. Кроме того, усложнена модель
данных, ведь она отвечает за описание переходов между состояниями в компонентах
приложения, никак не связанных друг с другом. В своем теперешнем виде этот класс
делает разделение кода на сервисы чрезвычайно трудным.
Одно из решений проблемы — упаковка класса Order в библиотеку и создание
центральной базы данных Order. Все сервисы, обрабатывающие заказы, станут ис
пользовать эту библиотеку и обращаться к одноименной БД. Проблема с данным
подходом состоит в том, что он нарушает ключевые принципы микросервисной
архитектуры и приводит к нежелательному жесткому связыванию. Например, лю
бое изменение структуры таблиц Order требует синхронного обновления кода со
стороны других команд.
Еще одно решение связано с инкапсуляцией БД Order в одноименный сервис,
который другие сервисы вызывают для получения и обновления заказов. Но в ре
зультате сервис Order отвечал бы только за данные и имел слабую доменную модель
с минимальным количеством бизнес-логики или вовсе без нее. Ни один из этих
вариантов не является привлекательным, но, к счастью, DDD предоставляет аль
тернативу.
Куда более удачным решением будет применение DDD и восприятие каждого
сервиса как отдельного поддомена со своей доменной моделью. Это означает, что
каждый сервис в приложении FTGO, имеющий какое-либо отношение к заказам,
будет иметь собственную модель с отдельной версией класса Order. Отличная ил
люстрация преимущества множественных доменных моделей — сервис Delivery.
Его представление класса Order (рис. 2.11) выглядит чрезвычайно просто: адрес
получения, время получения, адрес доставки, время доставки. К тому же вместо
Order используется более подходящее название — Delivery.
Рис. 2.11. Доменная модель сервиса Delivery
Сервис Delivery не заинтересован в остальных атрибутах Order.
Сервис Kitchen тоже имеет упрощенное представление заказа. Его версия клас
са Order называется Ticket. Он состоит из полей status, requestedDeliveryTime
и prepareByTime, а также списка позиций, благодаря которому в ресторане знают, что
нужно приготовить (рис. 2.12). Его не интересуют клиенты, оплата, доставка и т. д.
У сервиса Order самое сложное представление заказа (рис. 2.13). Но, несмотря на
большое количество полей и методов, оно все равно намного проще исходной версии.
92 Глава 2 • Стратегии декомпозиции
Рис. 2.12. Доменная модель сервиса Kitchen
Рис. 2.13. Доменная модель сервиса Order
В каждой доменной модели класс Order представляет разные аспекты одной и той
же бизнес-сущности. Приложение FTGO должно поддерживать согласованность
между разными объектами в разных сервисах. Например, авторизовав банковскую
карту клиента, сервис Order должен инициировать создание объекта Ticket в сервисе
Kitchen. Аналогично, если ресторан отклонил заказ через сервис Kitchen, он должен
быть отменен и в сервисе Order, а биллинговому сервису следует вернуть средства
клиенту. Из главы 4 вы узнаете, как поддерживать связанность между сервисами,
используя механизм повествований, основанный на событиях.
Помимо создания технических проблем, наличие множественных доменных мо
делей влияет на реализацию пользовательского интерфейса. Приложение должно
наладить связь между интерфейсом пользователя, который сам по себе является
отдельной доменной моделью, и доменными моделями каждого сервиса. Например,
в приложении FTGO состояние заказа, которое выводится клиенту, извлекается из
классов Order, принадлежащих нескольким сервисам. Эта связь часто реализуется
посредством API-шлюза, описанного в главе 8. Несмотря на эти сложности, поиск
и устранение божественных классов при создании микросервисной архитектуры
имеет первостепенное значение.
Теперь посмотрим, как определяются API сервисов.
2.2.6. Определение API сервисов
На данном этапе у нас есть перечень системных операций и список потенциальных
сервисов. Следующим шагом будет определение API каждого сервиса, его операций
и событий. Операция в API может существовать по одной из двух причин: она либо
соответствует системной операции и вызывается внешними клиентами (или, воз-
2.2. Определение микросервисной архитектуры приложения 93
можно, другими сервисами), либо поддерживает взаимодействие между сервисами
и вызывается только ими.
Сервис публикует события в основном для того, чтобы иметь возможность взаи
модействовать с другими сервисами. В главе 4 рассказывается, как эти события мож
но использовать для реализации повествований, обеспечивающих согласованность
данных между сервисами. А в главе 7 мы поговорим о том, как с помощью событий
обновлять CQRS-представления с поддержкой эффективных запросов. Приложение
также может задействовать события для уведомления внешних клиентов. Например,
оно может оповещать браузер с помощью WebSocket.
Первое, что нужно сделать при определении API, — привязать системную опера
цию к сервису. После этого требуется решить, должен ли сервис взаимодействовать
с другими сервисами для реализации системной операции. Если взаимодействие
необходимо, следует определить, какие API эти сервисы должны предоставить для
поддержки взаимодействия. Для начала посмотрим, как назначить сервису систем
ную операцию.
Назначение сервисам системных операций
Первым делом нужно определить, какой сервис будет служить начальной входя
щей точкой для запроса. Многие системные операции легко соотнести с сервисами,
но иногда этот процесс оказывается неочевидным. Возьмем, к примеру, операцию
notellpdatedLocation(), обновляющую местоположение курьера. С одной стороны,
она относится к курьерам, поэтому ее следовало бы назначить сервису Courier.
Но местоположение курьера нужно сервису Delivery. В данном случае назначение
операции сервису, которому нужна информация, возвращаемая этой операцией, —
наилучший выбор. В других ситуациях, возможно, имело бы смысл выбрать сервис,
обладающий данными, необходимыми для выполнения операции.
В табл. 2.2 перечислены сервисы приложения FTGO и операции, за которые они
отвечают.
Таблица 2.2. Привязка системных операций к сервисам в приложении FTGO
Сервис Операция
Consumer createConsumer()
Order createOrder()
Restaurant find AvailableRes tau rants()
Kitchen acceptOrder()
noteOrderReadyForPickup()
Delivery noteUpdatedLocation()
noteDeliveryPickedUp()
noteDeliveryDelivered()
Закончив с назначением системных операций, мы должны решить, какое взаи
модействие между сервисами требуется для выполнения каждой из них.
94 Глава 2 • Стратегии декомпозиции
Определение API, необходимых для поддержки взаимодействия
между сервисами
Некоторые системные операции целиком выполняются одним сервисом. Например,
в приложении FTGO сервис Consumer сам отвечает за операцию createConsumer().
В некоторых случаях для этого требуется несколько сервисов. Данные, необходи
мые для обработки одного из этих запросов, могут быть распределены по разным
сервисам. Например, для реализации операции createOrder() сервис Order должен
вызвать следующие сервисы, чтобы проверить предварительные условия и обеспе
чить выполнение окончательных.
□ Consumer — проверяет, может ли клиент разместить заказ, и получает его платеж
ную информацию.
□ Restaurant — проверяет позиции заказа, нахождение адреса и времени доставки
в пределах области обслуживания ресторана и набор минимальной суммы заказа,
а также получает цены на заказанные блюда.
□ Kitchen — создает объект Ticket.
□ Accounting — авторизует банковскую карту клиента.
Точно так же, чтобы реализовать системную операцию acceptOrder(), сервис Kitchen
должен обратиться к сервису Delivery, который назначит курьера для доставки заказа.
В табл. 2.3 перечислены сервисы, их исправленные API и то, с чем они взаимодействуют.
Чтобы полностью описать API сервиса, вы должны проанализировать каждую си
стемную операцию и определить, какое взаимодействие для этого требуется.
Таблица 2.3. Сервисы, их исправленные API и то, с чем они взаимодействуют
Сервис Операция Связанный сервис
Consumer verify ConsumerDetails() -
Order createOrder() Consumer Service
verifyConsumerDetails()
Restaurant Service
verifyOrderDetails()
Kitchen Service
createTicket()
Accounting Service
authorizeCard()
Restaurant findAvailableRestaurants()
verify ConsumerDetails()
—
Kitchen createTicket()
acceptOrder()
noteOrderReadyForPickup()
Delivery Servicesche
duleDelivery()
Delivery scheduleDelivery()
noteUpdatedLocation()
noteDeliveryPickedUp()
noteDeliveryDelivered()
Accounting authorizeCard() -
Резюме 95
Итак, мы определили сервисы и операции, которые каждый из них реализует.
Но важно помнить, что намеченная нами архитектура очень абстрактна. Мы не вы
брали конкретной технологии IPC. Более того, несмотря на то что термин «опера
ция» подразумевает некий IPC-механизм с синхронными запросами и ответами, вы
увидите, что асинхронные сообщения играют здесь важную роль. Архитектурные
концепции, описываемые на страницах книги, влияют на то, как эти сервисы будут
взаимодействовать между собой.
В главе 3 описываются конкретные технологии IPC, включая такие механиз
мы синхронного взаимодействия, как REST и асинхронный обмен сообщениями
с помощью брокера. Мы поговорим о том, как синхронное взаимодействие может
сказаться на доступности, и познакомимся с концепцией автономных сервисов,
которые не обращаются к другим компонентам приложения синхронно. Одним из
методов реализации автономного сервиса является использование шаблона CQRS,
рассматриваемого в главе 7. Сервис Order, например, мог бы хранить копию данных,
принадлежащих сервису Restaurant, — это позволило бы ему устранить необхо
димость в синхронном вызове Restaurant для проверки заказа. Для поддержания
копии в актуальном состоянии он может подписаться на события, которые сервис
Restaurant публикует при каждом обновлении своих данных.
В главе 4 вы познакомитесь с концепцией повествований и узнаете, как сервисы,
участвующие в повествовании, координируются с помощью асинхронных сообще
ний. Помимо надежного обновления информации, разбросанной по нескольким
сервисам, повествование позволяет реализовать автономный сервис. Например,
я покажу, как реализовать с помощью повествований операцию сreateOrder(), кото
рая обращается к сервисам Consumer, Kitchen и Accounting, используя асинхронные
сообщения.
В главе 8 описывается концепция API-шлюза, который делает API доступны
ми для внешних клиентов. Вместо того чтобы просто направить запрос к сервису,
API-шлюз может реализовать запрашивающую операцию, задействуя шаблон объ
единения API (это будет показано в главе 7). Логика API-шлюза собирает данные,
необходимые запросу, обращаясь к нескольким сервисам и объединяя полученные
результаты. В этом случае системная операция назначается не сервису, а API-
шлюзу. Сервисы будут реализовывать операции-запросы, которые использует
API-шлюз.
Резюме
□ Архитектура определяет качественные характеристики приложения, влияющие
непосредственно на темпы разработки: поддерживаемость, тестируемость и раз
вертываемость.
□ Микросервисная архитектура — это архитектурный стиль, который делает при
ложение хорошо поддерживаемым, тестируемым и развертываемым.
□ Сервисы в микросервисной архитектуре организованы вокруг бизнес-аспектов
(бизнес-возможностей или поддоменов), а не технических характеристик.
96 Глава 2 • Стратегии декомпозиции
□ Существует два вида декомпозиции.
• Декомпозиция по бизнес-возможностям, которая берет начало в бизнес-ар-
хитектуре.
• Декомпозиция по поддоменам, основанная на предметно-ориентированном
проектировании.
□ Вы можете избавиться от божественных классов, которые приводят к запутан
ным зависимостям и препятствуют декомпозиции, применяя DDD и определяя
отдельную доменную модель для каждого сервиса.
Межпроцессное
взаимодействие
в микросервисной
архитектуре
Мэри и ее команда, как и большинство других разработчиков, имеют определенный
опыт использования механизмов межпроцессного взаимодействия (IPC). Приложе
ние FTGO предоставляет интерфейс REST API, который применяется в мобильных
клиентах и JavaScript на стороне браузера. Оно также задействует множество облач
ных сервисов, таких как Twilio (для обмена сообщениями) и Stripe (для платежей).
Но, поскольку проект FTGO монолитный, его модули общаются друг с другом, вы
зывая методы и функции на уровне языка. Разработчикам FTGO обычно не нужно
задумываться об IPC, разве что они имеют дело с REST API или модулями, которые
интегрируются с облачными сервисами.
98 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Для сравнения: как вы видели в главе 2, микросервисная архитектура струк
турирует приложение в виде набора сервисов. Чтобы обрабатывать запросы, этим
сервисам часто приходится взаимодействовать между собой. Поскольку экземпляры
сервисов обычно представляют собой процессы, запущенные на разных компьюте
рах, они должны общаться друг с другом с помощью IPC. В микросервисной архи
тектуре межпроцессное взаимодействие играет намного более важную роль, чем
в монолитных приложениях. Таким образом, по мере разбиения своего монолита
на микросервисы Мэри и остальным разработчикам FTGO нужно будет потратить
много времени на обдумывание стратегий IPC.
Дефицита разнообразных механизмов 1РС точно не наблюдается. Сейчас мод
ным выбором является REST (с JSON). Хотя важно помнить, что идеальных реше
ний, подходящих для любых ситуаций, не бывает. Вы должны тщательно проанали
зировать доступные варианты. В этой главе рассматриваются различные виды IPC,
включая REST и обмен сообщениями, и взвешиваются все за и против.
Выбор механизма IPC — важное архитектурное решение, он может повлиять
на уровень доступности приложения. Кроме того, как я продемонстрирую в этой
и следующей главах, IPC пересекается даже с управлением транзакциями. Я от
даю предпочтение архитектуре, состоящей из слабо связанных сервисов, которые
взаимодействуют между собой с помощью асинхронных сообщений. Синхронные
протоколы, такие как REST, в основном используются для общения с другими при
ложениями.
Начну эту главу с обзора межпроцессного взаимодействия в микросервисной
архитектуре. Затем опишу механизм IPC, основанный на удаленном вызове проце
дур, самый популярный пример которого — REST. Мы поговорим о том, как обна
ружить сервисы и справиться с частичными отказами. После этого перейдем к IPC
на основе асинхронных сообщений. Далее рассмотрим масштабирование клиентов
с сохранением порядка следования сообщений, корректную обработку дубликатов
и транзакционный обмен сообщениями. В конце пройдемся по концепции автоном
ных сервисов, которые обрабатывают синхронные запросы без обращения к другим
сервисам, чем улучшают доступность.
3.1. Обзор межпроцессного взаимодействия
в микросервисной архитектуре
В вашем распоряжении множество разных технологий IPC. Сервисы могут ис
пользовать коммуникационные механизмы на основе запросов/ответов, такие как
REST или gRPC, поверх HTTP. Альтернативным вариантом являются асинхронные
механизмы коммуникации на основе сообщений, такие как AMQP или STOMP.
Существует также множество других форматов сообщений — это могут быть как
текстовые форматы, понятные человеку (JSON или XML), так и более эффективные
двоичные, такие как Avro или Protocol Buffers.
3.1. Обзор межпроцессного взаимодействия в микросервисной архитектуре 99
Прежде чем погружаться в подробности тех или иных технологий, я хочу по
говорить о нескольких проблемах проектирования, которые вы должны учитывать.
Начнем со стилей взаимодействия — это способы описания взаимодействия между
клиентами и сервисами, не привязанные к конкретным технологиям. Затем обсу
дим важность четкого определения API в микросервисной архитектуре и затронем
концепцию проектирования, строящегося вокруг API. После этого перейдем к такой
важной теме, как развитие API. В конце рассмотрим разные виды форматов сообще
ний и то, насколько они могут упростить развитие API.
3.1.1. Стили взаимодействия
Прежде чем выбирать механизм IPC для API сервиса, полезно будет подумать о сти
ле взаимодействия между сервисом и его клиентами. Это поможет сосредоточиться
на требованиях и не увязнуть в деталях конкретной технологии IPC. К тому же
выбор стиля взаимодействия влияет на уровень доступности вашего приложения
(об этом будет говориться в разделе 3.4). Более того, как вы увидите в главах 9 и 10,
это помогает выбрать подходящую стратегию интеграционного тестирования.
Существует много разных стилей взаимодействия между клиентом и сервисом.
Как видно в табл. 3.1, их можно разделить на два уровня. Первый уровень опреде
ляет выбор между отношениями «один к одному» и «один ко многим»:
□ «один к одному» — каждый клиентский запрос обрабатывается ровно одним
сервисом;
□ «один ко многим» — каждый запрос обрабатывается несколькими сервисами.
Второй уровень определяет выбор между синхронным и асинхронным взаимо
действием:
□ синхронное — клиент рассчитывает на своевременный ответ от сервиса и может
даже заблокироваться на время ожидания;
□ асинхронное — клиент не блокируется, а ответ, если таковой придет, может быть
отправлен не сразу.
Таблица 3.1. Различные стили взаимодействия можно охарактеризовать на двух уровнях: «один
к одному» или «один ко многим», а также синхронное или асинхронное
Взаимодействие Один к одному Один ко многим
Синхронное Запрос/ответ -
Асинхронное Асинхронный запрос/ответ,
однонаправленные уведомления
Издатель/подписчик,
издатель/асинхронные ответы
Далее перечислены разные виды взаимодействия «один к одному».
□ Запрос/ответ — клиент отправляет сервису запрос и ждет ответа. Он рассчи
тывает на то, что ответ придет своевременно, и может даже заблокироваться
100 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
на время ожидания. Этот стиль взаимодействия обычно приводит к жесткой
связанности сервисов.
□ Асинхронный запрос/ответ — клиент отправляет запрос, а сервис отвечает асин
хронно. Клиент не блокируется на время ожидания, поскольку сервис может
долго не отвечать.
□ Однонаправленные уведомления — клиент отправляет сервису запрос, не ожидая
(и не получая) ничего в ответ.
Важно помнить, что стиль взаимодействия с синхронными запросами/ответами
очень опосредованно относится к технологиям IPC. Например, для взаимодействия
сервисов в стиле «запрос/ответ» можно использовать как REST, так и обмен со
общениями. Даже если два сервиса задействуют брокер сообщений, клиентский
сервис может блокироваться в ожидании ответа. Это вовсе не означает, что они слабо
связаны. К этому мы вернемся позже, во время обсуждения того, как межсервисное
взаимодействие влияет на уровень доступности.
Далее перечислены виды взаимодействия «один ко многим».
□ Издатель/подписчик — клиент публикует сообщение с уведомлением, которое
потребляется любым количеством заинтересованных сервисов.
□ Издатель/асинхронные ответы — клиент публикует сообщение с запросом
и ждет определенное время ответа от заинтересованных сервисов.
Сервисы обычно используют сочетание этих стилей взаимодействия. Многие
сервисы в приложении FTGO предоставляют для выполнения операций как син
хронные, так и асинхронные API, а также публикуют события.
Посмотрим, как описать API сервиса.
3.1.2. Описание API в микросервисной архитектуре
API являются одним из важнейших аспектов разработки программного обеспече
ния. Приложение состоит из модулей. У каждого модуля есть интерфейс, опреде
ляющий набор операций, которые могут вызываться клиентами модуля. Хорошо
спроектированный интерфейс делает доступными полезные функции, скрывая
при этом их реализацию. Благодаря этому внутренние изменения не влияют на
клиентов.
В монолитном приложении интерфейсы обычно описываются с помощью кон
струкций языка программирования — например, в виде Java-интерфейса, который
определяет набор методов, доступных клиенту. Класс-реализация от клиента спря
тан. Более того, поскольку Java — статически типизированный язык, любая несо
вместимость между интерфейсом и клиентом будет мешать компиляции.
API и локальные интерфейсы играют одинаково важную роль в микросервис
ной архитектуре. API сервиса является контрактом между ним и его клиентами.
Как говорилось в главе 2, API состоит из операций, которые клиент может вызывать,
и событий, публикуемых сервисом. У операции есть имя, параметры и тип возвра-
3.1. Обзор межпроцессного взаимодействия в микросервисной архитектуре 101
щаемого значения. Событие имеет тип и набор полей, оно публикуется в канале
сообщений (это описано в разделе 3.3).
Трудность заключается в том, что определение API сервиса не основано на
простых конструкциях языка программирования. Как и полагается, сервис и его
клиенты компилируются отдельно. Если будет развернута новая версия сервиса с не
совместимым API, вы не получите ошибку компиляции — вместо этого возникнут
сбои на этапе выполнения.
Независимо от того, какой механизм IPC вы выберете, важно создать четкое
определение API сервиса, используя некий язык описания интерфейсов (interface
definition language, IDL). Кроме того, существуют хорошие аргументы в пользу того,
что описание сервиса должно начинаться с его API (см. www.programmableweb.com/
news/how-to-design-great-apis-api-first-design-and-raml/how-to/2015/07/10). Первым делом
вы описываете интерфейс. Затем рассматриваете полученный результат с клиент
скими разработчиками. И только после окончания работы над API реализуете сам
сервис. Такой подход повышает шансы на то, что ваш сервис будет удовлетворять
требованиям клиентов.
Характер определения API зависит от выбранного механизма IPC. Например,
если вы используете обмен сообщениями, API будет состоять из каналов, типов
и форматов сообщений. Если применяете HTTP, API будет основан на URL-адресах,
HTTP-командах и форматах запроса и ответа. Позже в этой главе я покажу, как
описываются API.
API сервиса редко оказывается неизменным. Скорее всего, он будет эволюцио
нировать со временем. Посмотрим, как это происходит и с какими проблемами вы
можете столкнуться.
3.1.3. Развивающиеся API
API непременно меняются со временем по мере добавления новых, изменения су
ществующих и иногда удаления старых возможностей. В монолитном приложении
изменение API и обновление кода, который его вызывает, — довольно простые
процессы. Если вы применяете статически типизированный язык, компилятор
выдаст вам список соответствующих ошибок. Единственной трудностью может
102 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
оказаться масштаб изменений: на обновление широко используемого API может
уйти много времени.
В приложениях, основанных на микросервисах, изменение API сервиса куда слож
нее. Клиентами сервиса выступают другие сервисы, которые часто разрабатываются
другими командами, или даже посторонние приложения за пределами организации.
Обычно синхронное обновление всех клиентов вместе с сервисом не представляется
возможным. Кроме того, поскольку современные приложения, как правило, не оста
навливаются на время обслуживания, обновление, скорее всего, будет плавающим,
то есть новая и старая версии сервиса станут работать параллельно.
Чтобы справиться с этими трудностями, необходимо иметь стратегию. То, как
происходит изменение API, зависит от характера изменения.
Семантическая нумерация версий
Спецификация семантического версионирования (semver.org) — хорошее руководство
по нумерации версий API. Это набор правил, регламентирующих, как использовать
и увеличивать номера версий. Семантическое версионирование изначально созда
валось для программных пакетов, но вы можете задействовать его для нумерации
версий API в распределенных системах.
Согласно спецификации семантического версионирования (Semver) номер версии
должен состоять из трех частей: MAJOR.MINOR. PATCH. Каждая часть должна инкремен
тироваться следующим образом:
□ MAJOR — при внесении в API несовместимого изменения;
□ MINOR — при изменении API с сохранением обратной совместимости;
□ PATCH — при исправлении ошибки с сохранением обратной совместимости.
Номера версий в API можно использовать в нескольких местах. Если вы реализу
ете REST API, мажорную версию можно указать в качестве первого элемента URL-
пути (как показано далее). Если сервис задействует обмен сообщениями, можете
включать номер версии в публикуемые сообщения. Целями являются корректное
версионирование API и их контролируемое развитие. Посмотрим, как вносятся
минорные и мажорные изменения.
Внесение минорных изменений с обратной совместимостью
В идеале следует стремиться к тому, чтобы все ваши изменения были обратно со
вместимыми. Такие изменения API являются добавочными:
□ добавление новых атрибутов к запросу;
□ добавление атрибутов к ответу;
□ добавление новых операций.
Если вы всегда будете делать только такие изменения, старые клиенты смогут
работать с новыми сервисами. Но для этого необходимо соблюдать принцип устой-
3.1. Обзор межпроцессного взаимодействия в микросервисной архитектуре 103
чивости (en.wikipedia.org/wiki/Robustness_principle), который гласит: «Будь консервативен
в собственных действиях и либерален к тому, что принимаешь от других». Сервисы
должны предоставлять значения по умолчанию для пропущенных атрибутов запро
са. В то же время клиентам следует игнорировать любые лишние атрибуты ответа.
Чтобы это не вызвало проблем, клиенты и сервисы должны использовать формат
запросов/ответов, который поддерживает принцип устойчивости. Позже в этом раз
деле вы увидите, как такие текстовые форматы, как JSON и XML в целом упрощают
развитие API.
Внесение мажорных, ломающих изменений
Иногда в API приходится вносить мажорные, несовместимые изменения. Поскольку
вы не можете сделать так, чтобы клиенты сразу же обновились, сервис должен не
которое время поддерживать одновременно старую и новую версии API. Если вы
используете механизм IPC, основанный на HTTP (такой как REST), мажорную
версию можно сделать частью U RL. Например, пути к версии 1 будут иметь префикс
' /vl/...', а пути к версии 2 — ' /v2/...'.
Еще один вариант: применить механизм согласования содержимого в HTTP
и включить номер версии в MIME-тип. Например, чтобы обратиться к версии 1.x
сервиса Order, клиент выполнит такой запрос:
GET /orders/xyz НТТР/1.1
Accept: application/vnd.example.resource+json; version=l
Этот запрос говорит сервису Order о том, что клиент ожидает получить ответ
версии 1.x.
Для поддержки нескольких версий API у сервиса есть адаптеры, логика которых
позволяет переходить между старой и новой версиями. К тому же, как описано в гла
ве 8, API-шлюз практически всегда использует версионированные API. Он также
может поддерживать множество старых версий интерфейса.
Теперь поговорим о форматах сообщений, выбор которых может повлиять на то,
насколько легко будет развивать ваш API.
3.1.4. Форматы сообщений
Суть IPC состоит в обмене сообщениями. Сообщения обычно содержат данные, вы
бор формата для которых является важным архитектурным решением. Это может
повлиять на эффективность IPC, удобство API и простоту его развития. Если вы
применяете систему обмена сообщениями или протоколы вроде HTTP, выбор фор
мата ложится на вас. Некоторые механизмы IPC, такие как gRPC (мы познакомимся
с ним чуть позже), могут сами диктовать формат сообщений. В любом случае важно
не привязываться к конкретному языку. Даже если сейчас вы используете только
один язык программирования, в будущем ситуация может измениться. Например,
не следует выбирать сериализацию Java.
104 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Форматы сообщений делятся на две категории: текстовые и двоичные. Рассмо
трим каждую из них.
Текстовые форматы сообщений
В первую категорию входят текстовые форматы, такие как JSON и XML. Они не нуж
даются в дополнительном описании, помимо того что их может прочитать чело
век. JSON-сообщение представляет собой набор именованных свойств. Точно так
же XML-сообщение, в сущности, является перечнем именованных элементов и зна
чений. Этот формат позволяет потребителям выбирать интересующие их данные,
игнорируя все остальное. Таким образом, многие изменения структуры сообщения
могут быть обратно совместимыми.
Структура XML-документов определяется их спецификацией (www.w3.org/XML/
Schema). Со временем сообщество разработчиков пришло к тому, что JSON тоже
нуждается в подобном механизме. Одно из популярных решений состоит в исполь
зовании стандарта JSON Schema (json-schema.org). JSON schema определяет имена
и типы свойств внутри сообщения и то, обязательны ли они. JSON schema может
предоставлять не только полезную документацию, но и механизм для проверки
входящих сообщений.
Недостаток применения текстовых форматов связан с тем, что сообщения полу
чаются довольно объемными, особенно если речь идет об XML. Помимо самих зна
чений, сообщение также предоставляет их имена. Еще одной отрицательной стороной
являются накладные расходы на разбор текста, особенно в ходе работы с большими
сообщениями. Следовательно, если вам важны эффективность и производитель
ность, лучше подумать об использовании двоичных форматов.
Двоичные форматы сообщений
Вы можете выбрать из нескольких двоичных форматов. К самым популярным
относятся Protocol Buffers (developers.google.com/protocol-buffers/docs/overview) и Avro
(avro.apache.org). Оба эти формата предоставляют типизированный язык IDL для
описания структуры ваших сообщений. Затем компилятор генерирует код, который
их сериализует и десериализует. Вы обязаны начинать проектирование сервисов
с API! Более того, если пишете клиентский код на статически типизированном языке,
компилятор проверит корректность использования API.
Одно из различий между этими форматами состоит в том, что Protocol Buffers за
действует маркированные поля, тогда как клиент Avro должен знать спецификацию
сообщения, чтобы его интерпретировать. В итоге Protocol Buffers делает развитие
API проще по сравнению с Avro. Отличное сравнение Thrift, Protocol Buffers и Avro
выполнено в статье martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-
buffers-thrift.html.
Рассмотрев форматы сообщений, мы можем перейти к конкретным механизмам
IPC, которые эти сообщения транспортируют. Начнем с удаленного вызова про
цедур.
3.2. Взаимодействие на основе удаленного вызова процедур 105
3.2. Взаимодействие на основе удаленного
вызова процедур
При помощи механизма удаленного вызова процедур (remote procedure invocation,
RPI) клиент отправляет запрос сервису, а тот его обрабатывает и возвращает ответ.
Некоторые клиенты могут блокироваться в ожидании ответа, а другие поддержи
вают реактивную, неблокирующую архитектуру. Но, в отличие от использования
сообщений, клиент рассчитывает на своевременное получение ответа.
Принцип работы RPI показан на рис. 3.1. Клиентская бизнес-логика обраща
ется к прокси-интерфейсу, реализованному классом-адаптером RPI-прокси. RPI-
прокси выполняет запрос к сервису. Запрос обрабатывается классом-адаптером
RPI-сервер, который вызывает бизнес-логику сервиса через интерфейс. Затем от
вет передается обратно в RPI-прокси, который возвращает результат клиентской
бизнес-логике.
Рис. 3.1. Бизнес-логика клиента обращается к интерфейсу, реализованному классом-адаптером
RPI-прокси. А он выполняет запрос к сервису. Класс-адаптер RPI-сервера обрабатывает запрос,
вызывая бизнес-логику сервиса
Прокси-интерфейс обычно инкапсулирует исходный коммуникационный про
токол. Таких протоколов существует великое множество. В этом разделе я опи
шу REST и gRPC. Покажу, как повысить уровень доступности ваших сервисов
106 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
с помощью адекватной реакции на частичные сбои, и объясню, почему микросер-
висное приложение, задействующее RPI, должно иметь механизм обнаружения
сервисов.
Сначала взглянем на REST.
3.2.1. Использование REST
В наши дни стало модным разрабатывать API в стиле RESTful (https://ru.wikipedia.org/
wiki/REST и en.wikipedia.org/wiki/Representational_state_transfer). REST — это механизм IPC,
который задействует I1TTP (почти всегда). Рой Филдинг (Roy Fielding), создатель
REST, дает следующее определение этой технологии: REST предоставляет набор
архитектурных ограничений, которые, если их применять как единое целое, делают
акцент на масштабируемости взаимодействия между компонентами, обобщенности
интерфейсов, независимом развертывании компонентов и промежуточных компонен
тах, чтобы снизить латентность взаимодействия, обеспечить безопасность и ин
капсулировать устаревшие системы (www.ics.uci.edu/~fielding/pubs/dissertation/top.htm).
Ресурс является ключевой концепцией в REST. Он представляет отдельный
бизнес-объект, такой как Customer или Product, или коллекцию бизнес-объектов.
Для работы с ресурсами REST использует HTTP-команды, которые указываются
с помощью URL. Например, GET-запрос возвращает представление ресурса, часто
в виде XML-документа или объекта JSON, хотя допускаются и другие форматы,
в том числе двоичные. POST-запрос создает новый ресурс, а PUT-запрос обновляет
существующий. У сервиса Order, к примеру, есть конечные точки POST /orders и GET
/orders/forderld} для создания нового и соответственно извлечения существу
ющего заказа.
Многие разработчики считают, что их API, основанные на HTTP, соответству
ют требованиям RESTful. Но, как утверждает Рой Филдинг в статье на roy.gbiv.com/
untangled/2008/rest-apis-must-be-hypertext-driven, это не всегда верно. Чтобы понять, по
чему так происходит, рассмотрим модель зрелости REST.
Модель зрелости REST
Леонард Ричардсон (Leonard Richardson) (однофамилец автора этой книги)
предлагает крайне полезную модель зрелости для REST (martinfowler.com/articles/
richardsonMaturityModel.html), которая состоит из следующих уровней.
□ Уровень 0, Клиенты обращаются к сервису этого уровня путем выполнения
HTTP-запроса типа POST к его единственной конечной точке (URL). Каждый
запрос указывает выполняемое действие, цель этого действия (например, бизнес-
объект) и различные параметры.
□ Уровень 1. Сервисы этого уровня поддерживают концепцию ресурса. Чтобы вы
полнить какое-то действие с ресурсом, клиент выполняет POST-запрос, указывая
действие и различные параметры.
3.2. Взаимодействие на основе удаленного вызова процедур 107
□ Уровень 2. Для выполнения действий сервисы второго уровня используют НТТР-
команды: GET для извлечения, POST для создания и PUT — для обновления.
Для задания параметров действия служат параметры и тело запроса, если тако
вые имеются. Это позволяет сервисам задействовать инфраструктуру протокола
HTTP, включая кэширование GET-запросов.
□ Уровень 3. Архитектура сервисов третьего уровня основана на принципе с ужас
ным названием — HATEOAS (Hypertext as the Engine of Application State — ги
пертекст в качестве ядра для состояния приложения). Основной его смысл в том,
что представление ресурса, возвращаемое GET-запросом, содержит ссылки для
выполнения действий с этим ресурсом. Например, клиент может отменить за
каз с помощью ссылки в представлении, полученном в результате GET-запроса,
который этот заказ извлек. В число преимуществ HATEOAS входит то, что
вам больше не нужно встраивать URL-адреса в клиентский код (www.infoq.com/
news/2009/04/hateoas-restful-api-advantages).
Я советую вам просмотреть интерфейсы REST API в своей организации и по
думать над тем, каким уровням они соответствуют.
Описание REST API
Как упоминалось в разделе 3.1, для определения API необходимо использовать
язык описания интерфейсов (interface definition language, IDL). В отличие от старых
коммуникационных протоколов, таких как CORBA и SOAP, у REST изначально
не было IDL. К счастью, сообщество разработчиков заново открыло для себя цен
ность такого языка в контексте RESTful API. Самым популярным REST IDL
является спецификация Open API Specification (www.openapis.org). Она берет нача
ло в открытом проекте Swagger, который представляет собой набор инструментов
для разработки и документирования интерфейсов REST API. Он включает в себя
утилиты для генерации клиентских заглушек и серверных каркасов на основе опре
деления интерфейса.
Трудности извлечения нескольких ресурсов за один запрос
REST-ресурсы обычно ориентируются на бизнес-объекты, такие как Consumer или
Order. Следовательно, при проектировании REST API часто возникает проблема
с тем, как позволить клиенту извлекать несколько родственных объектов за один за
прос. Представьте, например, что REST-клиент хочет извлечь информацию о заказе
и его заказчике. Если строго следовать стандарту REST API, для этого потребуется
как минимум два запроса: один для Order, другой — для Consumer. В более сложном
сценарии нам пришлось бы передавать данные туда и обратно больше двух раз, что
сказалось бы на времени отклика.
Чтобы решить эту проблему, можно позволить клиенту извлекать не только
сам ресурс, но и все объекты, которые с ним связаны. Например, клиент мог бы
108 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
извлечь заказ и информацию о заказчике с помощью запроса GET /orders/order-id-
1345?expand=consumer. В параметре запроса указан ресурс, который нужно вернуть
вместе с заказом. Этот подход хорош во многих сценариях, но в более сложных си
туациях его обычно не хватает. К тому же на его реализацию может потребоваться
слишком много времени. Все это сделало популярными альтернативные техноло
гии построения API, такие как GraphQL (graphql.org) и Netflix Falcor (netflix.github.io/
falcor/), изначально рассчитанные на эффективное извлечение данных.
Трудности с привязкой операций к НТТР-командам
Еще одна распространенная проблема проектирования REST API связана с тем,
как привязать операции, которые вы хотите выполнять с бизнес-объектом, к НТТР-
команде. REST API должен использовать команду PUT для обновления, но обно
вить заказ можно разными способами, например отменить, отредактировать и т. д.
К тому же обновление может оказаться неидемпотентным, чем нарушит правила
применения PUT. Одно из решений состоит в определении подресурса для обнов
ления отдельных аспектов объекта. Например, у сервиса Order может существовать
конечная точка POST /orders/ {orderldj/cancel для отмены заказов и POST /orders/
(orderld}/revise для их редактирования. Еще одним решением может стать задание
команды в параметре запроса. К сожалению, ни одно из них до конца не отвечает
принципам RESTful.
Трудности с привязкой операций к НТТР-командам играют на руку альтернати
вам REST, таким как gRPC (подробнее о них — в подразделе 3.2.2). Но прежде, чем
перейти к ним, рассмотрим преимущества и недостатки REST.
Преимущества и недостатки REST
Стандарт REST обладает множеством положительных качеств.
□ Он простой и привычный.
□ API на основе HTTP можно тестировать в браузере, используя, к примеру, рас
ширение Postman, или в командной строке с помощью curl (при условии, что вы
применяете JSON или другой текстовый формат).
□ Он имеет встроенную поддержку стиля взаимодействия вида «запрос/ответ».
□ Протокол HTTP, естественно, дружествен к брандмауэрам.
□ Он не нуждается в промежуточном брокере, что упрощает архитектуру системы.
Однако использование REST имеет и недостатки.
□ Он поддерживает лишь стиль взаимодействия вида «запрос/ответ».
□ Степень доступности снижена. Поскольку клиент и сервис взаимодействуют
между собой напрямую, без промежуточного звена для буферизации сообщений,
они оба должны работать на протяжении всего обмена данными.
□ Клиенты должны знать местонахождение (URL) экземпляра (-ов) сервиса.
Как описывается в подразделе 3.2.4, это нетривиальная проблема для современ-
3.2. Взаимодействие на основе удаленного вызова процедур 109
ных приложений. Для определения местонахождения экземпляров сервисов кли
ентам приходится использовать так называемый механизм обнаружения сервисов.
□ Извлечение нескольких ресурсов за один запрос связано с определенными труд
ностями.
□ Иногда непросто привязать к HTTP-командам несколько операций обновления.
Несмотря на эти недостатки, REST считается стандартом де-факто для постро
ения API, хотя у него есть несколько любопытных альтернатив. GraphQL, к примеру,
реализует гибкое и эффективное извлечение данных (этот стандарт обсуждается
в главе 8, в которой речь пойдет также о шаблоне API-шлюза).
Еще одна альтернатива REST — технология gRPC. Посмотрим, как она работает.
3.2.2. Использование gRPC
Как упоминалось в предыдущем разделе, одна из трудностей применения REST
связана с тем, что HTTP поддерживает ограниченный набор команд, из-за чего про
цесс проектирования REST API с поддержкой нескольких операций обновления
не всегда оказывается простым. Одна из технологий, которой удается избежать
этой проблемы, — gRPC (www.grpc.io). Это фреймворк для написания многоязыч
ных клиентов и серверов (см. ru.Wikipedia.org/wiki/Удалённый-ВызоВ-Процедур). gRPC
представляет собой двоичный протокол на основе сообщений. Как вы помните из
обсуждения двоичных форматов, это означает, что проектирование сервиса должно
начинаться с его API. API в gRPC описывается с помощью языка IDL на основе
Protocol Buffers — многоязычного механизма сериализации структурированных
данных от компании Google. Компилятор Protocol Buffer генерирует клиентские
заглушки и серверные каркасы. Он поддерживает разные языки, включая Java,
С#, NodeJS и Go Lang. Клиенты и серверы обмениваются сообщениями в формате
Protocol Buffers, используя НТТР/2.
gRPC API состоит из одного или нескольких определений сервисов и сообще
ний вида «запрос/ответ». Определение сервиса является аналогом интерфейса в Java
и представляет собой набор строго типизированных методов. Помимо простых вы
зовов, состоящих из запроса и ответа, gRPC поддерживает поточный RPC. Сервер
может вернуть клиенту поток сообщений. В то же время клиент может сам отправить
поток сообщений на сервер.
В качестве формата сообщений gRPC использует Protocol Buffers. Как уже
упоминалось, это эффективный и компактный двоичный формат. Он является мар
кируемым. Каждое поле сообщения нумеруется и содержит код типа. Получатель
сообщения может извлечь нужные ему поля, а остальные, которые не распознает, —
проигнорировать. В итоге gRPC позволяет развивать API с сохранением обратной
совместимости.
В листинге 3.1 показан фрагмент интерфейса gRPC API для сервиса Order.
Он описывает несколько методов, включая createOrder(). Этот метод принимает
CreateOrderRequest в качестве параметра и возвращает CreateOrderReply.
110 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Листинг 3.1. Фрагмент интерфейса gRPC API для сервиса Order
service Orderservice {
rpc createOrder(CreateOrderRequest)
rpc cancelOrder(CancelOrderRequest)
rpc reviseOrder(ReviseOrderRequest)
returns (CreateOrderReply) {}
returns (CancelOrderReply) {}
returns (ReviseOrderReply) {}
}
message CreateOrderRequest {
int64 restaurantld = 1;
int64 consumerld = 2;
repeated Lineitem lineitems = 3;
}
message Lineitem {
string menultemld = 1;
int32 quantity = 2;
}
message CreateOrderReply {
int64 orderld = 1;
}
Сообщения CreateOrderRequest и CreateOrderReply являются типизированными.
Например, CreateOrderRequest содержит поле restaurantld типа int64, а значение
его метки равно 1.
Протокол gRPC обладает несколькими преимуществами.
□ Он позволяет легко спроектировать API с богатым набором операций обнов
ления.
□ Он имеет эффективный компактный механизм IPC, что особенно явно проявля
ется при обмене крупными сообщениями.
□ Поддержка двунаправленных потоков делает возможными стили взаимодействия
на основе RPI и обмена сообщениями.
□ Он позволяет сохранять совместимость между клиентами и сервисами, написан
ными на совершенно разных языках.
У gRPC есть и несколько недостатков.
□ Процесс работы с API, основанным на gRPC, оказывается для JavaScript-
клиентов более трудоемким, чем с API, основанным на REST/JSON.
□ Старые брандмауэры могут не поддерживать НТТР/2.
Протокол gRPC — это полноценная альтернатива REST, хотя оба они представ
ляют собой синхронные коммуникационные механизмы и, как следствие, страдают
из-за проблем с частичным отказом. Давайте подробнее поговорим об этом недо
статке и посмотрим, как с ним справиться.
3.2. Взаимодействие на основе удаленного вызова процедур 111
3.2.3. Работа в условиях частичного отказа
с применением шаблона «Предохранитель»
Каждый раз, когда сервис в распределенной системе делает синхронный запрос
к другому сервису, возникает риск частичного отказа. 11оскольку сервис является от
дельным процессом, он может не ответить вовремя па запрос клиента. Или оказаться
недоступным из-за сбоя, или находиться в процессе технического обслуживания.
Кроме того, сервис может быть перегруженным и отвечать на запросы чрезвычайно
медленно.
Поскольку клиент блокируется в ожидании ответа, существует опасность того,
что его собственные клиенты тоже окажутся заблокированными и так по цепочке
откажет вся система.
Представьте: сервис Order перестал отвечать (рис. 3.2). Мобильный клиент делает
REST-запрос к API-шлюзу, который, как вы увидите в главе 8, служит для клиентов
API входной точкой в приложение. API-шлюз проксирует запрос к недоступному
сервису Order.
Рис. 3.2. API-шлюз должен защищаться от неотзывчивых сервисов, таких как Order
Оптимистичная реализация OrderServiceProxy блокировалась бы до бесконеч
ности в ожидании ответа. Это плохо сказалось бы не только па удобстве использо
вания, но и во многих случаях на потреблении цепных ресурсов, таких как потоки.
Рано или поздно ресурсы закончились бы, и API-шлюз не смог бы обслуживать
запросы. Весь API стал бы недоступным.
112 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
При проектировании сервисов необходимо позаботиться о том, чтобы частичный
отказ не мог распространиться по всему приложению. Решение этой задачи состоит
из двух частей.
□ Вы должны использовать RPI-прокси наподобие OrderServiceProxy, чтобы
справляться с недоступными удаленными сервисами.
□ Вам нужно решить, как восстановиться после отказа удаленного сервиса.
Для начала посмотрим, как написать надежный RPI-прокси.
Разработка надежных RPI-прокси
Каждый раз, когда один сервис вызывает другой, он должен защитить себя с по
мощью метода, предложенного компанией Netflix (techblog.netflix.com/2012/02/fault-
tolerance-in-high-volume.html). Этот подход сочетает в себе следующие механизмы.
□ Сетевое время ожидания. Никогда не блокируйтесь бессрочно, всегда отсчи
тывайте время ожидания запроса. Это гарантирует, что когда-нибудь ресурсы
освободятся.
□ Ограничение количества неудачных запросов от клиента к сервису. Установите
лимит максимального количества неудавшихся запросов, которые клиент может
послать определенному сервису. При исчерпании этого лимита выполнение даль
нейших запросов, скорее всего, будет бессмысленным, поэтому такие попытки
должны сразу завершаться ошибкой.
□ Шаблон «Предохранитель». Отслеживайте количество успешных и неудавшихся
запросов. Если частота ошибок превысит некий порог, разомкните предохрани
тель, чтобы дальнейшие попытки сразу же завершались. Большое количество
неудачных запросов говорит о том, что сервис недоступен и обращаться к нему
не имеет смысла. По истечении какого-то периода клиент должен предпринять
новую попытку и, если она окажется успешной, замкнуть предохранитель.
Netflix Hystrix (github.com/Netflix/Hystrix) — это библиотека с открытым исходным
кодом, которая реализует эти и другие шаблоны. Если вы работаете с JVM, вам
определенно стоит подумать об использовании Hystrix при реализации RPI-прокси.
Если же имеете дело со средой, не основанной на JVM, следует применять аналогич
ную библиотеку. Например, в сообществе .NET популярен проект Polly (github.com/
App-vNext/Polly).
Восстановление после отказа сервиса
Работа с такой библиотекой, как Hystrix, — лишь часть решения. Помимо этого, вы
должны определиться с тем, как каждый отдельный сервис должен реагировать на не
доступность удаленного сервиса. Один из вариантов — простое возвращение ошибки
своему клиенту. Такой подход, к примеру, имеет смысл в ситуации, представленной
на рис. 3.2, где запрос на создание заказа оказывается неудачным. В этом случае API-
шлюз может сделать лишь одно — вернуть ошибку мобильному клиенту.
3.2. Взаимодействие на основе удаленного вызова процедур 113
В других сценариях может оказаться предпочтительнее вернуть резервное
значение, то есть либо значение по умолчанию, либо закэшированный ответ.
Например, в главе 7 описывается реализация API-шлюзом запрашивающей опе
рации findOrder() с использованием шаблона объединения API. Как видно на
рис. 3.3, реализованная таким образом конечная точка GET /orders/{orderld} вы
зывает несколько сервисов, включая Order, Kitchen и Delivery, а затем объединяет
их ответы.
Рис. 3.3. API-шлюз реализует конечную точку GET /orders/{orderld} путем объединения API.
Он вызывает несколько сервисов, агрегирует их ответы и возвращает результат мобильному
приложению. Код, реализующий конечную точку, должен иметь стратегию на случай отказа
любого из сервисов, которые он вызывает
Вполне вероятно, что для клиента данные разных сервисов важны в разной
степени. Данные сервиса Order оказываются необходимыми. Если этот сервис
недоступен, API-шлюз должен вернуть либо закэшированную версию его ответа,
либо ошибку. Данные других сервисов не так важны. Например, клиент сможет
вывести пользователю полезную информацию даже в том случае, если состояние
доставки окажется недоступным. Если сервис Delivery не отвечает, API-шлюз
должен вернуть закэшированную версию его данных или вовсе исключить его из
ответа.
Умение справляться с частичными отказами должно быть неотъемлемой спо
собностью ваших сервисов, но это не единственная проблема, которую необходимо
решить при использовании RPI. Чтобы один сервис мог удаленно вызывать проце
дуры из другого, он должен знать местоположение конкретного экземпляра сервиса
в сети. На первый взгляд здесь нет ничего сложного, но на самом деле это довольно
непростая проблема. Вы должны задействовать механизм обнаружения сервисов.
Посмотрим, как это работает.
114 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
3.2.4. Обнаружение сервисов
Представьте, что вы пишете код, который вызывает сервис через REST API. Для вы
полнения запроса этому коду необходимо знать местоположение экземпляра сервиса
в сети (IP-адрес и порт). В традиционных приложениях, работающих на физическом
оборудовании, сетевое местоположение экземпляров сервисов обычно статическое.
Ваш код, к примеру, мог бы извлечь сетевые адреса из конфигурационного файла,
который время от времени обновляется. Но в современных микросервисных при
ложениях, основанных на облачных технологиях, все может быть не так просто.
Современная система куда более динамичная (рис. 3.4).
Рис. 3.4. Экземпляры сервисов имеют динамически назначаемые IP-адреса
Сетевое местоположение назначается экземплярам сервисов динамически. Более
того, набор этих экземпляров постоянно меняется из-за автоматического масшта
бирования, отказов и обновлений. Из-за этого ваш клиент должен использовать
обнаружение сервисов.
Обзор механизмов обнаружения сервисов
Как вы только что видели, клиент нельзя сконфигурировать статически, предоставив
ему IP-адреса сервисов. Приложение должно задействовать механизм динамическо
го обнаружения. По своей сути обнаружение сервисов является довольно простым:
его ключевым компонентом выступает реестр сервисов — база данных с информа
цией о том, где находятся экземпляры сервисов приложения.
Когда экземпляры сервисов запускаются и останавливаются, механизм обнару
жения обновляет реестр. Когда клиент обращается к сервису, механизм обнаруже
ния получает список его доступных экземпляров, запрашивая реестр, и направляет
запрос одному из них.
3.2. Взаимодействие на основе удаленного вызова процедур 115
Есть два основных способа реализации механизма обнаружения сервисов.
□ Сервисы и их клиенты напрямую взаимодействуют с реестром.
□ За обнаружение сервисов отвечает инфраструктура развертывания (подробнее
об этом — в главе 12).
Рассмотрим оба эти варианта.
Применение шаблонов обнаружения сервисов на уровне приложения
Один из способов реализации обнаружения сервисов заключается в том, что сервисы
приложения и их клиенты взаимодействуют с реестром сервисов. На рис. 3.5 по
казано, как это работает. Для вызова сервиса клиент сначала обращается к реестру,
чтобы получить список его экземпляров, а затем шлет запрос одному из них.
Рис. 3.5. Реестр сервисов отслеживает их экземпляры. Клиенты обращаются к реестру,
чтобы найти сетевое местоположение доступных экземпляров сервиса
Данный подход к обнаружению сервисов сочетает в себе два шаблона. Первый —
это саморегистрация. Экземпляр сервиса обращается к API реестра, чтобы зарегистри
ровать свое сетевое местоположение. Он также может предоставить URL-адрес для
проверки работоспособности (подробнее об этом — в главе 11). Этот URL-адрес являет
ся конечной точкой API, которую реестр периодически запрашивает, чтобы убедиться
116 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
в том, что сервис работает в нормальном режиме и доступен для обработки запросов.
Реестр может требовать, чтобы экземпляр сервиса периодически вызывал API «серд
цебиения», который предотвращает истечение срока действия его регистрации.
Вторым шаблоном является обнаружение на клиентской стороне. Когда клиент хо
чет обратиться к сервису, он обращается к реестру, чтобы получить список его экзем
пляров. Для улучшения производительности клиент может кэшировать экземпляры
сервиса. После этого он использует алгоритм балансирования нагрузки, циклический
или случайный, чтобы выбрать конкретный экземпляр и отправить ему запрос.
Обнаружение сервисов на уровне приложения популяризируют компании Netflix
и Pivotal. Например, в Netflix были разработаны и выпущены под открытой лицен
зией несколько компонентов, таких как Eureka (высокодоступный реестр сервисов),
Java-клиент для Eureka и Ribbon (сложный HTTP-клиент с поддержкой клиента для
Eureka). Компания Pivotal разработала Spring Cloud — фреймворк на основе Spring,
который делает использование компонентов Netflix на удивление простым. Сервисы,
построенные с помощью Spring Cloud, автоматически регистрируются с помощью
Eureka, а клиенты, основанные на Spring Cloud, автоматически применяют Eureka
для обнаружения сервисов.
Одно из преимуществ обнаружения сервисов на уровне приложения — то, что
в нем предусмотрена возможность развертывания сервисов на разных платформах.
Представьте, к примеру, что лишь часть ваших сервисов развернута на Kubernetes
(см. главу 12), а остальные работают в устаревшей среде. Обнаружение сервисов на
уровне приложения с использованием Eureka будет охватывать обе среды, тогда как
решение на базе Kubernetes совместимо лишь с Kubernetes.
Один из недостатков этого подхода связан с тем, что он требует наличия библио
теки обнаружения сервисов для каждого языка, а возможно, и фреймворка, который
вы применяете. Spring Cloud может помочь лишь разработчикам на Spring. Если вы
используете какой-то другой Java-фреймворк или язык вне платформы JVM, такой
как NodeJS или GoLang, вам придется поискать другую библиотеку. Еще одной от
рицательной стороной обнаружения сервисов на уровне приложения является то,
что настройка и обслуживание реестра ложатся на вас — это будет вас отвлекать.
3.2. Взаимодействие на основе удаленного вызова процедур 117
В связи с этим обычно предпочтительно задействовать механизмы обнаружения,
предоставляемые инфраструктурой развертывания.
Применение шаблонов обнаружения сервисов,
предоставляемых платформой
Из главы 12 вы узнаете, что многие современные платформы развертывания, такие
как Docker и Kubernetes, имеют встроенные реестр и механизм обнаружения сер
висов. Платформа развертывания выдает каждому сервису DNS-имя, виртуальный
IP-адрес (VIP) и привязанное к нему доменное имя. Клиент делает запрос к DNS-
имени/VIP, а платформа развертывания автоматически направляет его к одному
из доступных экземпляров сервиса. В итоге регистрация и обнаружение сервисов,
а также маршрутизация запросов выполняются самой платформой. Как это работает,
показано на рис. 3.6.
Рис. 3.6. Платформа ответственна за регистрацию сервисов, обнаружение и маршрутизацию
запросов. Экземпляры сервисов заносятся в реестр регистратором. У каждого сервиса есть сетевое
местоположение, DNS-имя или виртуальный IP-адрес. Клиент выполняет запрос к сетевому
местоположению сервиса. Маршрутизатор обращается к реестру и распределяет запросы между
всеми доступными сервисами
118 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Платформа развертывания включает в себя реестр с информацией об IP-адресах
развернутых сервисов. В данном примере клиент обращается к сервису Order по
DNS-имени order-service, которое направляет к виртуальному IP-адресу 10.1.3.4,
Платформа автоматически распределяет запросы между экземплярами сервиса
Order.
Этот подход сочетает в себе два шаблона:
□ сторонней регистрации — сервис не прописывает себя в реестре сам, за него это
делает компонент регистратор, который обычно является частью платформы
развертывания и отвечает за регистрацию;
□ обнаружения на стороне сервера — вместо того чтобы обращаться к реестру,
клиент делает запрос к DNS-имени, которое направляет его к маршрутизатору
запросов, а тот в свою очередь идет в реестр и балансирует нагрузку.
Ключевое преимущество данного подхода состоит в том, что всеми аспектами
обнаружения сервисов занимается сама платформа. Ни клиенты, ни сервисы не со
держат никакого кода для этой цели. Благодаря этому механизм обнаружения
доступен для всех сервисов и клиентов независимо от языка или фреймворка, на
которых они написаны.
Один из недостатков метода связан с тем, что он поддерживает обнаружение
только тех сервисов, которые были развернуты на данной платформе. Например,
как отмечалось при описании обнаружения на уровне приложения, платформа
Kubernetes может обнаружить только те сервисы, которые на ней запущены. Несмо
тря на это ограничение, рекомендую использовать обнаружение сервисов на уровне
платформы везде, где это возможно.
Итак, мы рассмотрели синхронную модель IPC на примере REST и gRPC.
Теперь поговорим об альтернативе — асинхронном взаимодействии на основе
сообщений.
3.3. Взаимодействие с помощью асинхронного обмена сообщениями 119
3.3. Взаимодействие с помощью асинхронного
обмена сообщениями
Сервисы могут взаимодействовать путем асинхронного обмена сообщениями.
Приложения, основанные на этом подходе, обычно используют брокер сообщений,
который играет роль промежуточного звена между сервисами. Возможна и архитек
тура без брокера, когда сервисы взаимодействуют между собой напрямую. Клиент
шлет сервису запрос в виде сообщения. Если экземпляр сервиса должен ответить,
он сделает это, отправив отдельное сообщение клиенту. Поскольку взаимодействие
является асинхронным, клиент не блокируется в ожидании ответа — он написан
с расчетом на то, что ответ может прийти не сразу.
Начну этот раздел с обзора механизмов обмена сообщениями. Я покажу, как сде
лать архитектуру обмена сообщениями независимой от конкретной технологии. Далее
сравню архитектуры с брокером и без, выделю их основные отличия и опишу критерии
выбора брокера сообщений. После этого мы обсудим несколько важных тем, таких
как масштабирование потребителей с сохранением упорядоченности сообщений,
определение и отклонение дубликатов, а также отправка и прием сообщений в рамках
транзакции базы данных. Вначале посмотрим, как работает обмен сообщениями.
3.3.1. Обзор механизмов обмена сообщениями
Практичная модель обмена сообщениями описывается в книге Грегора Хопа (Gregor
Hohpe) и Бобби Вульфа (Bobby Woolf) Enterprise Integration Patterns (Addison-Wesley
Professional, 2003)1. В этой модели сообщения передаются по каналам. Отправитель
(приложение или сервис) пишет сообщение в канал, а получатель (приложение
или сервис) считывает его из этого канала. Начнем с сообщений, а затем перейдем
к каналам.
О сообщениях
Сообщение состоит из заголовка и тела (www.enterpriseintegrationpatterns.com/Message.html).
Заголовок — это набор пар «ключ — значение», метаданные, которые описывают
1 Хоп Г., Вульф Б. Шаблоны интеграции корпоративных приложений. — М.: Вильямс, 2016.
120 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
отправляемую информацию. Помимо ключей и значений, предоставляемых отпра
вителем, заголовок содержит такие данные, как идентификатор сообщения (предо
ставляется либо отправителем, либо инфраструктурой) и необязательный обратный
адрес, в котором указан канал, куда следует записывать ответ. Тело сообщения — это
отправляемые данные. Они могут иметь текстовый или двоичный формат.
Существует несколько разных видов сообщений.
□ Документ — обобщенное сообщение, содержащее только данные. Получатель сам
решает, как его интерпретировать. Пример документного сообщения — ответ на
команду.
□ Команда — сообщение, эквивалентное RPC-запросу. В нем указываются вызы
ваемая операция и ее параметры.
□ Событие — сообщение о том, что с отправителем произошло нечто заслужива
ющее внимания. Событие часто принадлежит к домену и представляет изменение
состояния доменного объекта, такого как Order или Customer.
Подход к микросервисной архитектуре, описанный в этой книге, подразумевает
активное использование команд и событий.
Теперь рассмотрим каналы — механизм, посредством которого взаимодействуют
сервисы.
О каналах сообщений
Как видно на рис. 3.7, сообщения передаются по каналам (www.enterpriseintegrationpat-
terns.com/MessageChannel.html). Бизнес-логика отправителя обращается к интерфейсу
исходящего порта, который инкапсулирует внутренний механизм взаимодействия.
Исходящий порт реализуется классом-адаптером отправителя, который передает со
общение получателю через канал. Канал сообщений — это инфраструктурная абстрак
ция. Для обработки сообщения вызывается класс-адаптер обработчика сообщений,
который обращается к интерфейсу входящего порта, реализованному бизнес-логикой
потребителя. Записывать сообщения в канал может любое количество отправителей.
Точно так же любое количество получателей может их оттуда читать.
Существует два вида каналов: «точка — точка» (www.enterpriseintegrationpatterns.com/
PointToPointChannel.html) и «издатель — подписчик» (www.enterpriseintegrationpatterns.com/
PublishSubscribeChannel.html).
□ Канал типа «точка — точка» доставляет сообщения ровно одному потребителю,
который считывает их оттуда. Сервисы используют такие каналы для взаимодей
ствия вида «один к одному», описанного ранее. Например, по каналам «точка —
точка» часто передают командные сообщения.
□ Канал типа «издатель — подписчик» доставляет каждое сообщение всем подклю
ченным потребителям. Сервисы применяют такие каналы для взаимодействия
вида «один ко многим», описанного ранее. Например, по каналам «издатель —
подписчик» обычно рассылают события.
3.3. Взаимодействие с помощью асинхронного обмена сообщениями 121
вх
од
ящ
ег
о
по
рт
а,
к
от
ор
ы
й
ре
ал
из
уе
тс
я
би
зн
ес
-л
ог
ик
ой
п
ол
уч
ат
ел
я
122 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
3.3.2. Реализация стилей взаимодействия
с помощью сообщений
Одно из полезных свойств механизмов обмена сообщениями — их гибкость, которой
достаточно для поддержки всех стилей взаимодействия, описанных в подразде
ле 3.1.1. Некоторые из этих стилей реализованы напрямую в виде обмена сообще
ниями, другие строятся поверх этого подхода.
Посмотрим, как реализовать каждый из стилей взаимодействия. Начнем с син
хронных и асинхронных запросов/ответов.
Реализация синхронных и асинхронных запросов/ответов
Клиент и сервис могут взаимодействовать между собой, отправляя запросы и при
нимая ответы. Если это происходит синхронно, клиент ожидает немедленного ответа
от сервиса, а если это асинхронное взаимодействие — не ждет. Обмен сообщениями
по своей природе асинхронен, поэтому он предоставляет лишь асинхронные запро-
сы/ответы. Но клиент может блокироваться, пока ответ не будет получен.
Чтобы реализовать стиль взаимодействия с асинхронными запросами/ответа-
ми, клиент и сервис обмениваются парными сообщениями. Как видно на рис. 3.8,
клиент отправляет в канал «точка — точка», принадлежащий сервису, командное
сообщение с указанием операции и ее параметров. Сервис обрабатывает запрос
и возвращает в канал «точка — точка», принадлежащий клиенту, ответное сообще
ние с результатом.
Рис. 3.8. Реализация асинхронных запросов/ответов путем включения ответного канала
и идентификатора в исходное сообщение. Получатель обрабатывает сообщение и шлет ответ
в заданный канал
3.3. Взаимодействие с помощью асинхронного обмена сообщениями 123
Клиент должен сообщить сервису, куда тому следует вернуть результат, и сопо
ставить ответное сообщение со своим запросом. К счастью, решить эти две задачи
несложно. Клиент отправляет командное сообщение с каналом ответа в заголовке.
Сервер записывает в этот канал свой ответ, содержащий идентификатор соот
ветствия с тем же значением, что и идентификатор запроса. Клиент использует
идентификатор соответствия, чтобы сопоставить свое сообщение с ответом.
Поскольку клиент и сервис взаимодействуют с помощью сообщений, их общение
изначально асинхронно. Теоретически клиент может заблокироваться, пока не полу
чит ответ, но на практике обработка ответов происходит асинхронно. Кроме того,
ответы обычно могут обрабатываться любым экземпляром клиента.
Реализация однонаправленных уведомлений
Реализация однонаправленных уведомлений с помощью асинхронных сообщений —
довольно простой процесс. Клиент шлет сообщение (обычно командное) в канал
типа «точка — точка», принадлежащий сервису. Сервис подписывается на этот
канал и обрабатывает сообщения. Он не возвращает ничего в ответ.
Реализация шаблона «издатель/подписчик»
Обмен сообщениями имеет встроенную поддержку стиля взаимодействия «из
датель/подписчик». Клиент публикует в канале типа «издатель — подписчик»
сообщение, которое считывается несколькими потребителями. Как описывается
в главах 4 и 5, сервисы используют этот стиль взаимодействия для публикации до
менных событий, которые представляют изменения в доменных объектах. Сервис,
публикующий доменное событие, владеет каналом типа «издатель — подписчик»,
чье название основано на доменном классе. Например, сервис Order публикует со
бытия Order в канале Order, а сервис Delivery публикует события Delivery в канале
Delivery. Сервису, который заинтересован в конкретном доменном объекте, доста
точно подписаться на соответствующий канал.
Реализация издателя/асинхронных ответов
«Издатель/асинхронные ответы» — это высокоуровневый стиль взаимодействия,
который сочетает в себе элементы шаблонов «издатель/подписчик» и «запрос/от-
вет». Клиент публикует в канале типа «издатель — подписчик» сообщение с каналом
ответа в заголовке. Потребитель записывает ответное сообщение с идентификато
ром соответствия в канал ответа. Клиент принимает ответы и сверяет их с запросом
с помощью идентификатора соответствия.
Каждый сервис в вашем приложении, который имеет асинхронный API, будет
использовать одну или несколько таких методик реализации. Если у сервиса есть
асинхронный API для вызова операций, у него должен быть также канал сообщений
для приема запросов. Аналогично сервис, публикующий события, будет задейство
вать для этого канал событий.
124 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Как отмечалось в подразделе 3.1.2, у API сервиса должна быть спецификация.
Посмотрим, как ее можно создать для асинхронного API.
3.3.3. Создание спецификации для API сервиса
на основе сообщений
Как показано на рис. 3.9, спецификация асинхронного API сервиса должна содер
жать имена каналов, а также типы и форматы сообщений, которыми обмениваются
в каждом из них. Форматы сообщений должны быть описаны с помощью стандартов
JSON, XML или Protobuf. Хотя, в отличие от REST и Open API, здесь нет широко
распространенного стандарта для документирования каналов и типов сообщений.
Вам придется написать неформальный документ.
Рис. 3.9. Асинхронный API сервиса состоит из каналов, а также таких типов сообщений,
как команды, ответы и события
Асинхронный API состоит из операций, вызываемых клиентами, и событий, кото
рые публикуют сервисы. Эти два аспекта документируются по-разному. Рассмотрим
каждый из них, начав с операций.
Документирование асинхронных операций
Операции сервиса можно вызывать с помощью одного из двух стилей взаимодей
ствия.
□ API в стиле «запрос/асиюсронный ответ» — состоит из канала команд сервиса,
типов и форматов командных сообщений, которые сервис принимает, а также
типов и форматов ответных сообщений, отправляемых сервисом.
□ API в стиле однонаправленных уведомлений — состоит из канала команд сервиса,
а также типов и форматов командных сообщений, которые принимает сервис.
3.3. Взаимодействие с помощью асинхронного обмена сообщениями 125
Сервис может использовать один и тот же канал как для асинхронных запросов/
ответов, так и для однонаправленных уведомлений.
Документирование публикуемых событий
Сервис может публиковать события, используя стиль взаимодействия «издатель/
подписчик». Спецификация API такого стиля состоит из канала событий, а также
типов и форматов сообщений, которые сервис в нем публикует.
Эта модель обмена сообщениями — отличная абстракция и хороший метод про
ектирования асинхронных API. Но для создания сервиса вам необходимо выбрать
технологию обмена сообщениями и определить, как с помощью ее возможностей
реализовать свою архитектуру. Посмотрим, как выглядит этот процесс.
3.3.4. Использование брокера сообщений
Приложения, основанные на сообщениях, обычно применяют брокер сообщений —
инфраструктурный компонент, через который сервисы общаются друг с другом.
Архитектура обмена сообщениями может и не иметь брокера, в этом случае сервисы
взаимодействуют между собой напрямую. Оба эти подхода приводятся на рис. 3.10.
У них есть разные достоинства и недостатки, но обычно решения с участием брокера
оказываются более удачными.
Рис. 3.10. В одной архитектуре сервисы взаимодействуют напрямую, а в другой — через брокер
сообщений
В этой книге основное внимание уделяется архитектуре с применением брокера,
но будет нелишним взглянуть и на альтернативный подход — в некоторых ситуациях
он может вам пригодиться.
126 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Обмен сообщениями без брокера
В архитектуре без брокера сервисы могут обмениваться сообщениями напрямую.
Проект ZeroMQ (zeromq.org) — популярная реализация этого подхода. Он представ
ляет собой как спецификацию, так и набор библиотек для разных языков. Проект
поддерживает разнообразные механизмы передачи данных, включая TCP, доменные
сокеты в стиле UNIX и многоадресное вещание.
Отсутствие брокера дает несколько преимуществ.
□ Более легковесный сетевой трафик и меньшие задержки, поскольку сообщения
передаются напрямую от отправителя к получателю и не должны проходить
через брокер.
□ Брокер сообщений не станет узким местом или единой точкой отказа.
□ Более простое администрирование, так как вам не нужно настраивать и обслу
живать брокер сообщений.
Но какими бы заманчивыми ни были эти преимущества, отсутствие брокера со
общений чревато существенными недостатками.
□ Сервисы должны знать о местонахождении друг друга и, следовательно, исполь
зовать один из механизмов обнаружения, описанных в подразделе 3.2.4.
□ Снижена степень доступности, поскольку отправитель и получатель должны
оставаться доступными на время передачи сообщения.
□ Возникают дополнительные трудности с реализацией таких механизмов, как
гарантированная доставка.
Пониженная доступность и потребность в обнаружении сервисов совпадают
с недостатками, присущими синхронным запросам/ответам.
Из-за этих ограничений в большинстве промышленных приложений использу
ется архитектура с брокером. Посмотрим, как это работает.
Обзор обмена сообщениями на основе брокера
Брокер — это промежуточное звено, через которое проходят все сообщения. Отправи
тель передает сообщение брокеру, а тот доставляет его получателю. Важным пре
имуществом этого подхода является то, что отправителю не нужно знать сетевое
местонахождение потребителя. Кроме того, брокер буферизирует сообщения, пока
у получателя не появится возможность их обработать.
Существует много разных брокеров сообщений. Далее перечислены популярные
проекты с открытым исходным кодом:
□ ActiveMQ (activemq.apache.org);
□ RabbitMQ (www.rabbitmq.com);
□ Apache Kafka (kafka.apache.org).
3.3. Взаимодействие с помощью асинхронного обмена сообщениями 127
Есть также облачные решения для обмена сообщениями, такие как AWS Kinesis
(https://aws.amazon.com/ru/kinesis/) и AWS SQS (https://aws.amazon.com/ru/sqs/).
При выборе брокера сообщений следует учитывать различные факторы, включая
следующие.
□ Поддерживаемые языки программирования. Лучше выбрать брокер с поддержкой
широкого диапазона языков программирования.
□ Поддерживаемые стандарты обмена сообщениями. Поддерживает ли брокер со
общений стандарт вроде AMQP или STOMP? Использует ли он свой закрытый
протокол?
□ Порядок следования сообщений. Сохраняет ли брокер порядок следования со
общений?
□ Гарантии доставки. Какие гарантии доставки дает брокер сообщений?
□ Постоянное хранение. Сохраняются ли сообщения на диск? Могут ли они пере
жить сбой брокера?
□ Устойчивость. Если потребитель переподключится к брокеру, получит ли он
сообщения, отправленные, пока он был отключен?
□ Масштабируемость. Насколько масштабируем брокер сообщений?
□ Латентность. Какова сквозная латентность?
□ Конкурирующие потребители. Поддерживает ли брокер сообщений конкуриру
ющих потребителей?
У каждого брокера есть свои плюсы и минусы. Например, брокер с низкой ла
тентностью может не сохранять порядок следования сообщений, не гарантировать
их доставку и хранить их исключительно в оперативной памяти. Гарантированная
доставка и надежное хранение сообщений на диске, скорее всего, будут стоить вам
повышенной латентности. То, какой брокер подходит лучше всего, зависит от тре
бований вашего приложения. Возможно даже, что у разных частей системы разные
требования к обмену сообщениями.
Хотя, наверное, важнейшими свойствами являются порядок следования и мас
штабируемость. Давайте поговорим о том, как реализовать каналы сообщений с по
мощью брокера.
Реализация каналов сообщений с помощью брокера
Все брокеры сообщений по-своему реализуют концепцию каналов (табл. 3.2). JMS-
брокеры, такие как ActiveMQ, имеют очереди и темы. Брокеры, основанные на
AMQP, как RabbitMQ, поддерживают обмены и очереди. У Apache Kafka есть темы,
у AWS Kinesi — потоки, а у AWS SQS — те же очереди. Более того, некоторые из
них обеспечивают более гибкий обмен сообщениями по сравнению с абстрактной
концепцией каналов, описанной в этой главе.
128 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Таблица 3.2. Брокеры сообщений реализуют концепцию каналов по-разному
Брокер сообщений Канал типа
«точка — точка»
Канал типа «издатель — подписчик»
JMS Очередь Тема
Apache Kafka Тема Тема
Брокеры на основе AMQP,
такие как RabbitMQ
Обмен + Очередь Обмен типа fanout и отдельная очередь
для каждого потребителя
AWS Kinesis Поток Поток
AWS SQS Очередь -
Почти все брокеры сообщений, описанные здесь, одновременно поддерживают
каналы типа «точка — точка» и «издатель — подписчик». Исключение — проект
AWS SQS, который поддерживает только каналы «точка — точка».
Теперь пройдемся по положительным и отрицательным сторонам обмена со
общениями на основе брокера.
Преимущества и недостатки обмена сообщениями
на основе брокера
Обмен сообщениями на основе брокера имеет множество преимуществ.
□ Слабая связанность. Для выполнения запроса клиенту нужно лишь отправить
сообщение в подходящий канал. Клиенту ничего не известно об экземплярах
сервиса, и ему не нужно использовать механизм обнаружения, чтобы определить
их местонахождение.
□ Буферизация сообщений. Брокер буферизирует сообщения до тех пор, пока их
не смогут обработать. В протоколах с синхронными запросами/ответами, таких
как HTTP, и клиент, и сервис должны быть доступны на протяжении всего обме
на данными. Сообщения же накапливаются в очереди, пока потребитель не будет
готов их принять. Это означает, что, к примеру, онлайн-магазин может прини
мать заказы от посетителей, даже если система выполнения заказов слишком
медленная или недоступна. Сообщения просто будут ожидать в очереди, пока
их не смогут обработать.
□ Гибкое взаимодействие. Обмен сообщениями поддерживает все стили взаимо
действия, описанные ранее.
□ Явное межпроцессное взаимодействие. Механизмы, основанные на RPC, пыта
ются сделать так, чтобы обращение к удаленному сервису выглядело словно
вызов локальной процедуры. Но законы физики и вероятность частичных от
казов делают эти два вида взаимодействия очень разными. Обмен сообщениями
делает различия явными, чтобы у разработчиков не возникало ложное чувство
безопасности.
3.3. Взаимодействие с помощью асинхронного обмена сообщениями 129
У обмена сообщениями есть и некоторые недостатки.
□ Потенциальное узкое место производительности. Существует риск того, что
брокер сообщений может стать узким местом производительности. Хорошо, что
многие современные брокеры спроектированы с поддержкой высокой масшта
бируемости.
□ Потенциальная единая точка отказа. Крайне важно, чтобы брокер сообщений
был высокодоступным, иначе может пострадать надежность системы. К счастью,
большинство современных брокеров спроектированы с поддержкой высокой
доступности.
□ Дополнительная сложность в администрировании. Механизм обмена сообще
ниями — это еще один системный компонент, который нужно устанавливать,
конфигурировать и администрировать.
Рассмотрим некоторые архитектурные проблемы, с которыми вы можете стол
кнуться.
3.3.5. Конкурирующие получатели
и порядок следования сообщений
Одна из трудностей связана с тем, как масштабировать получателей и сохранить
при этом порядок следования сообщений. Наличие нескольких экземпляров сервиса
для параллельной обработки сообщений является распространенным требованием.
Более того, даже один экземпляр, вероятно, будет использовать потоки, чтобы обра
батывать сообщения параллельно. Применение нескольких потоков и экземпляров
сервиса повышает пропускную способность приложения. Но из-за конкурентной
работы сложно гарантировать, что каждое сообщение будет обработано лишь один
раз и в правильном порядке.
Представьте, например, что у вас есть три экземпляра сервиса, которые читают из
одного канала типа «точка — точка», и издатель последовательно публикует события
Order Created, Order Updated и Order Cancelled. Примитивная реализация могла бы
параллельно доставить каждое сообщение своему получателю. Но по причине задер
жек, связанных с проблемами в сети или со сборкой мусора, сообщения могут быть
обработаны не в том порядке, что обусловит неожиданное поведение. Теоретически
экземпляр сервиса может обработать сообщения Order Cancelled до того, как другой
экземпляр обработает Order Created!
Распространенное решение, применяемое в таких современных брокерах сообще
ний, как Apache Kafka и AWS Kinesis, состоит в использовании сегментированных
(разделенных) каналов. На рис. 3.11 показано, как это работает. Решение состоит
из трех частей.
1. Сегментированный канал включает в себя два и более сегмента, каждый из ко
торых сам ведет себя как канал.
130 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
2. Отправитель указывает в заголовке сообщения ключ сегмента, который обыч
но представляет собой произвольную строку или последовательность байтов.
Брокер использует этот ключ, чтобы привязать сообщение к определенному
сегменту/разделу. Например, он может выбрать сегмент взятием остатка от цело
численного деления хеша сегментного ключа на количество сегментов.
3. Брокер сообщений группирует экземпляры получателя и обращается с ними
как с одним логическим получателем. В Apache Kafka, например, применяется
термин «группа потребителей». Брокер сообщений назначает каждый сегмент
отдельному получателю. При запуске и остановке получателей эта процедура
повторяется.
Рис. 3.11. Масштабирование потребителей с использованием сегментированного (разделенного)
канала, чтобы сохранить порядок следования сообщений. Отправитель включает в сообщение
ключ сегмента. Брокер записывает сообщение в сегмент, определенный на основе этого ключа,
и назначает каждый раздел экземпляру реплицированного получателя
В этом примере каждое событие Order содержит ключ сегмента в качестве
orderld. Каждое событие, связанное с конкретным заказом, публикуется в один
и тот же сегмент, который считывается одним экземпляром потребителя. В итоге
гарантируется упорядоченная обработка этих сообщений.
3.3.6. Дублирование сообщений
Еще одна проблема, которую необходимо решить при обмене сообщениями, свя
зана с дубликатами. В идеале брокер должен доставлять каждое сообщение ровно
один раз, но обеспечение таких гарантий обычно оказывается слишком затратным.
Вместо этого большинство брокеров обещают доставить сообщение как минимум
один раз.
Когда система работает в нормальном режиме, каждое сообщение доставляется
по одному разу. Но отказ клиента, сети или брокера сообщений может вызывать
множественную доставку. Допустим, клиент отказывает после обработки сообще
ния и обновления базы данных, но перед подтверждением сообщения. Тогда брокер
3.3. Взаимодействие с помощью асинхронного обмена сообщениями 131
доставит неподтвержденное сообщение повторно — либо тому же клиенту, когда он
перезапустится, либо копии другого клиента.
В идеале вы должны использовать брокер сообщений, который сохраняет поря
док следования при повторной доставке. Представьте, что клиент последовательно
обрабатывает события Order Created и Order Cancelled для одного и того же заказа,
но по какой-то причине событие Order Created не было подтверждено. В этом случае
брокер должен заново доставить оба сообщения, Order Created и Order Cancelled.
Если он повторно отправит лишь Order Created, клиент может проигнорировать
отмену заказа.
Существует несколько методов работы с повторяющимися сообщениями:
□ добавление в сообщения идемпотентных дескрипторов;
□ отслеживание и отклонение дубликатов.
Рассмотрим оба варианта.
Добавление в сообщения идемпотентных дескрипторов
Если программная логика, обрабатывающая сообщения, является идемпотентной,
дубликаты не несут в себе никакой опасности. Логика считается идемпотентной,
если ее многократное выполнение с идентичными входными значениями не име
ет дополнительных эффектов. Например, отмена уже отмененного заказа — это
идемпотентная операция. То же самое относится к созданию заказа с ID, который
предоставляется клиентом. Идемпотентный обработчик сообщений можно безопас
но вызвать несколько раз при условии, что брокер сохраняет порядок следования
во время повторной доставки.
К сожалению, программная логика не всегда оказывается идемпотентной.
Или же ваш брокер сообщений не сохраняет порядок следования при повторной
доставке. Дубликаты и сообщения, доставленные не в том порядке, могут привести
к ошибкам. В таких случаях ваши обработчики должны отслеживать сообщения
и отклонять дубликаты.
Отслеживание сообщений и отклонение дубликатов
Возьмем, к примеру, обработчик сообщений, который авторизует банковскую кар
ту клиента. Для каждого заказа он должен выполнить ровно одну авторизацию.
Такая программная логика будет иметь разный эффект при каждом последующем
вызове. Если за-за продублированных сообщений она выполнится несколько раз,
приложение поведет себя некорректно. Обработчик сообщений, реализующий эту
логику, обязан быть идемпотентным, для этого он должен определять и отклонять
повторяющиеся сообщения.
В качестве простого решения можно сделать так, чтобы потребитель отслежи
вал обработанные сообщения с помощью идентификаторов и отклонял любые
дубликаты. Например, он может сохранять в базе данных идентификатор каждого
132 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
сообщения, которое обработал. Как это сделать с использованием отдельной табли
цы БД, показано на рис. 3.12.
Рис. 3.12. Потребитель определяет и отклоняет повторяющиеся сообщения, записывая в таблицу
базы данных идентификаторы уже обработанных. Если сообщение было обработано прежде,
операция INSERT в таблице PROCESSED.MESSAGES завершится неудачно
Когда потребитель обрабатывает сообщение, он записывает его ID в таблицу базы
данных как часть транзакции по созданию и обновлению бизнес-объектов. В этом
примере потребитель вставляет строку с идентификатором сообщения в таблицу
PROCESSED_MESSAGES. Если происходит дублирование, операция INSERT завершится
неудачно и потребитель сможет отклонить сообщение.
Еще один вариант состоит в том, что обработчик записывает идентификаторы
сообщений не в отдельную таблицу, а в таблицу приложения. Этот подход особен
но полезен при использовании баз данных NoSQL, которые имеют ограниченную
транзакционную модель и не поддерживают обновление двух таблиц в рамках одной
транзакции. Пример приведен в главе 7.
3.3.7. Транзакционный обмен сообщениями
Сервису часто нужно публиковать сообщения в рамках транзакции, обновляющей
базу данных. На страницах этой книги вы найдете примеры того, как сервисы пу
бликуют доменные события при каждом обновлении или создании бизнес-объектов.
Обновление базы данных и отправка сообщения должны происходить в пределах
одной транзакции, иначе сервис может обновить БД и, например, отказать до того,
как сообщение будет отправлено. Если не выполнять эти две операции атомарно,
сбой может оставить систему в несогласованном состоянии.
Транзакционное решение подразумевает использование распределенных транз
акций, которые охватывают как БД, так и брокер сообщений. Но, как вы увидите
в главе 4, распределенные транзакции — неудачное решение для современных при
ложений. Кроме того, они не поддерживаются во многих современных брокерах,
таких как Apache Kafka.
3.3. Взаимодействие с помощью асинхронного обмена сообщениями 133
В связи с этим приложения должны задействовать другой механизм для надеж
ной публикации сообщений. Рассмотрим его.
Использование таблицы базы данных
в качестве очереди сообщений
Представьте, что ваше приложение применяет реляционную базу данных. Простой
способ надежной публикации сообщений — с помощью шаблона «Публикация
событий». Он использует таблицу БД в качестве временной очереди сообщений.
У сервиса, отправляющего сообщения, есть таблица OUTBOX (рис. 3.13). В рамках
транзакции, которая создает, обновляет и удаляет бизнес-объекты, сервис шлет со
общения, вставляя их в эту таблицу. Поскольку это локальная ACID-транзакция,
атомарность гарантируется.
Рис. 3.13. Для надежной публикации сообщения сервис вставляет его в таблицу OUTBOX в рамках
транзакции по обновлению базы данных. Ретранслятор сообщений считывает таблицу OUTBOX
и передает сообщения брокеру
Таблица OUTBOX играет роль временной очереди сообщений. Ретранслятор
(MessageRelay) — это компонент, который читает таблицу OUTBOX и передает со
общения брокеру.
Аналогичный подход можно применять и к некоторым базам данных NoSQL.
Каждый бизнес-объект, хранящийся в виде записи внутри БД, имеет атрибут со
134 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
списком сообщений, которые нужно опубликовать. Обновляя этот объект, сервис
добавляет в список новое сообщение. Это атомарная операция, поскольку она вы
полняется за один запрос к базе данных. Трудность данного подхода связана с эффек
тивным поиском бизнес-объектов, содержащих события, и их публикацией.
Существует несколько способов доставки сообщений от базы данных к брокеру.
Рассмотрим каждый из них.
Публикация событий с помощью шаблона
«Опрашивающий издатель»
Если приложение использует реляционную базу данных, сообщения, вставленные
в таблицу OUTBOX, можно опубликовать очень простым способом: ретранслятору до
статочно запросить из таблицы неопубликованные записи. Таблица периодически
опрашивается:
SELECT * FROM OUTBOX ORDERED BY ... ASC
Затем ретранслятор публикует эти сообщения брокеру, отправляя их по соот
ветствующим каналам. В конце он удаляет сообщения из таблицы OUTBOX:
BEGIN
DELETE FROM OUTBOX WHERE ID in (...)
COMMIT
Опрос базы данных — это простой подход, который неплохо работает в неболь
ших масштабах. Его недостатком является то, что частое обращение к БД может ока
заться затратным. К тому же его можно использовать только с теми базами данных
NoSQL, которые поддерживают соответствующие запросы. Это связано с тем, что
вместо таблицы OUTBOX приложению приходится запрашивать бизнес-объекты, а это
не всегда можно сделать эффективно. Часто из-за этих недостатков и ограничений
предпочтительнее (а в некоторых случаях необходимо) задействовать более тонкий
и производительный метод — отслеживание транзакционного журнала.
Публикация событий с применением шаблона
«Отслеживание транзакционного журнала»
Менее тривиальное решение заключается в том, что ретранслятор отслеживает
транзакционный журнал базы данных, который называют еще журналом фиксации.
Каждое зафиксированное обновление, выполненное приложением, представлено
в виде записи в журнале транзакций БД. Вы можете прочитать этот журнал и опу-
3.3. Взаимодействие с помощью асинхронного обмена сообщениями 135
бликовать каждое изменение в качестве сообщения для брокера. Принцип работы
этого подхода показан на рис. 3.14.
Рис. 3.14. Сервис публикует сообщения, вставленные в таблицу OUTBOX, извлекая
информацию из транзакционного журнала БД
Анализатор журнала транзакций читает записи в транзакционном журнале.
Каждую подходящую запись, которая соответствует добавлению сообщения, он
преобразует в событие и публикует его для брокера. Этот подход годится для публи
кации сообщений, записанных в таблицу СУБД или добавленных в список записей
в базе данных NoSQL.
Существует несколько примеров реализации этого подхода.
□ Debezium (debezium.io) — проект с открытым исходным кодом, который публикует
изменения базы данных для брокера сообщений Apache Kafka.
□ LinkedIn Databus (github.com/linkedin/databus) — проект с открытым исходным ко
дом, который анализирует журнал транзакций Oracle и публикует изменения
в виде событий. Компания LinkedIn использует Databus для синхронизации
различных производных источников данных с системой записей.
136 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
□ DynamoDB streams (docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.
html) — создает поток упорядоченных по времени изменений (создание, обновле
ние и удаление), произошедших с записями своих таблиц за последние 24 часа.
Приложение может читать эти изменения из потока и, например, публиковать
их в виде событий.
□ Eventuate Tram (github.com/eventuate-tram/eventuate-tram-core) — моя собственная
библиотека с открытым исходным кодом для транзакционного обмена сообще
ниями, которая использует протокол двоичного журнала MySQL, Postgres WAL
или просто проверяет изменения, внесенные в таблицу OUTBOX, и публикует их
в Apache Kafka.
Это не самый простой подход, но работает он на удивление хорошо. Основная
трудность состоит в том, что его реализация требует от разработчиков некоторых
усилий. Вы могли бы, к примеру, написать низкоуровневый код для вызова API
баз данных. Или, как вариант, воспользоваться открытым фреймворком, таким как
Debezium, который публикует в Apache Kafka изменения, вносимые приложением
в MySQL, Postgres или MongoDB. Недостатком Debezium является то, что он пред
назначен для перехвата изменений на уровне БД и API для отправки и получения
сообщений находятся вне его зоны ответственности. В связи с этим я создал фрейм
ворк Eventuate Tram, который предоставляет API для обмена сообщениями, а также
отслеживания и опрашивания транзакционного журнала.
3.3.8. Библиотеки и фреймворки для обмена
сообщениями
Для отправки и получения сообщений сервис должен использовать какую-то
библиотеку. Это может быть клиентская библиотека брокера сообщений, хотя не
посредственное ее применение чревато несколькими проблемами.
□ Клиентская библиотека привязывает бизнес-логику, публикующую сообщения,
к API брокера.
□ Клиентские библиотеки брокеров обычно низкоуровневые, поэтому на отправку
и получение сообщений потребуется много строчек кода. Разработчики предпо
читают не писать один и тот же шаблонный код по многу раз. К тому же, как автор
этой книги, я не хочу засорять свои примеры низкоуровневым кодом.
□ Клиентские библиотеки обычно предоставляют лишь базовый механизм от
правки и получения сообщений, без поддержки высокоуровневых стилей взаи
модействия.
Лучше было бы использовать более высокоуровневые библиотеки или фрейм
ворки, которые скрывают низкоуровневые детали и непосредственно поддерживают
высокоуровневые стили взаимодействия. Для простоты в примерах в этой книге
задействуется мой фреймворк Eventuate Tram. Он имеет простой и понятный API,
который скрывает от вас сложности использования брокера сообщений. Помимо
3.3. Взаимодействие с помощью асинхронного обмена сообщениями 137
интерфейса для отправки и получения сообщений Eventuate Tram поддерживает
также высокоуровневые стили взаимодействия, такие как асинхронные запросы/
ответы и публикация доменных событий.
Eventuate Tram реализует два важных механизма.
□ Транзакционный обмен сообщениями — публикует сообщения в рамках транзак
ции базы данных.
□ Обнаружение дубликатов — потребитель сообщений в Eventuate Tram обнару
живает и отклоняет повторяющиеся сообщения. Это очень важно, потому что,
как обсуждалось в подразделе 3.3.6, потребитель должен обработать каждое со
общение ровно один раз.
Рассмотрим API Eventuate Tram.
Простой обмен сообщениями
Для простого обмена сообщениями предусмотрено два Java-интерфейса: MessagePro-
ducer и Messageconsumer. Сервис-отправитель использует Messageproducer для пу
бликации сообщений в канале. Вот пример работы с этим интерфейсом:
Messageproducer messageproducer = ...;
String channel = ...;
String payload = ...;
messageProducer.send(destination, MessageBuilder.withPayload(payload).build())
Сервис-потребитель применяет интерфейс Messageconsumer, чтобы подписаться
на сообщения:
Messageconsumer messageconsumer;
messageconsumer.subscribe(subscriberld, Collections.singleton(destination),
message -> { ... })
138 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Messageproducer и Messageconsumer являются фундаментом высокоуровневых
API для асинхронных запросов/ответов и публикации доменных событий.
Поговорим о том, как публикуются события и как на них подписаться.
Публикация доменных событий
У Eventuate Tram есть API для публикации и потребления доменных событий.
В главе 5 объясняется, что доменными являются события, которые генерируются
агрегатом (бизнес-объектом) при его создании, обновлении или удалении. Сервис
публикует доменные события с помощью интерфейса DomainEventPublisher, на
пример:
DomainEventPublisher domainEventPublisher;
String accountld = ...;
DomainEvent domainEvent = new AccountDebited(...);
domainEventPublisher.publish("Account"accountId, Collections.singletonList(
domainEvent));
Сервис потребляет доменные события с помощью класса DomainEventDispatcher.
Пример показан далее:
DomainEventHandlers domainEventHandlers = DomainEventHandlersBuilder
.forAggregateType("Order")
.onEvent(AccountDebited.class, domainEvent -> { ... })
.build();
new DomainEventDispatcher("eventDispatcherld",
domainEventHandlers,
messageconsumer);
События не единственный высокоуровневый шаблон обмена сообщениями, ко
торый поддерживает Eventuate Tram. Можно использовать также сообщения вида
« команда /ответ».
Сообщения вида «команда/ответ»
Клиент может послать сервису командное сообщение, используя интерфейс
Commandproducer, например:
Commandproducer commandproducer = ...;
Map<String, String> extraMessageHeaders = Collections.emptyMap();
String commandld = commandProducer.sendC'CustomerCommandChannel",
new DoSomethingCommandO,
"ReplyToChannel",
extraMessageHeaders);
3.4. Использование асинхронного обмена сообщениями 139
Для потребления командных сообщений сервис использует класс CommandDis-
patcher, который подписывается на определенные события с помощью интерфейса
Messageconsumer. Он передает каждое командное сообщение подходящему методу-
обработчику. Далее приведен пример:
CommandHandlers commandHandlers =CommandHandlersBuilder
.fromChannel(commandChannel)
.onMessage(DoSomethingCommand.class, (command) -
> { ... ; return withSuccess(); })
.build();
CommandDispatcher dispatcher = new CommandDispatcher("subscribeId",
commandHandlers, messageconsumer, messageproducer);
По мере чтения книги вы будете встречать примеры кода, которые используют
эти API для отправки и получения сообщений.
Как вы можете увидеть сами, фреймворк Eventuate Tram реализует транзакци
онный обмен сообщениями для Java-приложений. Он предоставляет два вида ин
терфейсов: низкоуровневый для отправки и получения сообщений транзакционным
способом и высокоуровневые для публикации и потребления доменных событий,
а также обработки команд.
Теперь обсудим подход к проектированию сервисов, который улучшает доступ
ность за счет асинхронного обмена сообщениями.
3.4. Использование асинхронного
обмена сообщениями для улучшения
доступности
Как вы видели, разнообразные механизмы IPC подталкивают вас к различным
компромиссам. Один из них связан с тем, как механизм IPC влияет на доступность.
В этом разделе вы узнаете, что синхронное взаимодействие с другими сервисами
в рамках обработки запросов снижает степень доступности приложения. В связи
с этим при проектировании своих сервисов вы должны по возможности использо
вать асинхронный обмен сообщениями.
Сначала посмотрим, какие проблемы создает синхронное взаимодействие и как
это сказывается на доступности.
3.4.1. Синхронное взаимодействие снижает степень
доступности
REST — это чрезвычайно популярный механизм IPC. У вас может возникнуть со
блазн использовать его для межсервисного взаимодействия. Но проблема REST
заключается в том, что это синхронный протокол: HTTP-клиенту приходится ждать,
пока сервис не вернет ответ. Каждый раз, когда сервисы общаются между собой по
синхронному протоколу, это снижает доступность приложения.
140 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Чтобы понять, почему так происходит, рассмотрим сценарий, представленный на
рис. 3.15. У сервиса Order есть интерфейс REST API для создания заказов. Для про
верки заказа он обращается к сервисам Consumer и Restaurant, которые тоже имеют
REST API.
Рис. 3.15. Сервис Order обращается к другим сервисам по REST. Это простой способ,
но он требует, чтобы все сервисы были доступны одновременно, а это понижает
доступность API
Создание заказа состоит из такой последовательности шагов.
1. Клиент делает HTTP-запрос POST /orders к сервису Order.
2. Сервис Order извлекает информацию о заказчике, выполняя HTTP-запрос GET /
consumers/id к сервису Consumer.
3. Сервис Order извлекает информацию о ресторане, выполняя HTTP-запрос GET /
restaurant/id к сервису Restaurant.
4. Order Taking проверяет запрос, задействуя информацию о заказчике и ресторане.
5. Order Taking создает заказ.
6. Order Taking отправляет HTTP-ответ клиенту.
Поскольку эти сервисы используют HTTP, все они должны быть доступны, чтобы
приложение FTGO смогло обработать запрос CreateOrder. Оно не сможет создать
заказ, если хотя бы один из сервисов недоступен. С математической точки зрения
доступность системной операции является произведением доступности сервисов,
которые в нее вовлечены. Если сервис Order и те два сервиса, которые он вызывает,
имеют доступность 99,5 %, то их общая доступность будет 99,5 %3 = 98,5 %, что на
много ниже. Каждый последующий сервис, участвующий в запросе, делает операцию
менее доступной.
Эта проблема не уникальна для взаимодействия на основе REST. Доступность
снижается всякий раз, когда для ответа клиенту сервис должен получить ответы
от других сервисов. Здесь не поможет даже переход к стилю взаимодействия «за
прос/ответ» поверх асинхронных сообщений. Например, если сервис Order пошлет
сервису Consumer сообщение через брокер и примется ждать ответа, его доступность
ухудшится.
3.4. Использование асинхронного обмена сообщениями 141
Если вы хотите максимально повысить уровень доступности, минимизируйте
объем синхронного взаимодействия. Посмотрим, как это сделать.
3.4.2. Избавление от синхронного взаимодействия
Существует несколько способов уменьшения объема синхронного взаимодей
ствия с другими сервисами при обработке синхронных запросов. Во-первых, чтобы
полностью избежать этой проблемы, все сервисы можно снабдить исключительно
асинхронными API. Но это не всегда возможно. Например, публичные API обыч
но придерживаются стандарта REST. Поэтому некоторые сервисы обязаны иметь
синхронные API.
К счастью, чтобы обрабатывать синхронные запросы, вовсе не обязательно вы
полнять их самому. Поговорим о таких вариантах.
Использование асинхронных стилей взаимодействия
В идеале все взаимодействие должно происходить в асинхронном стиле, описанном
ранее в этой главе. Представьте, к примеру, что клиент приложения FTGO при
меняет для создания заказов асинхронный стиль взаимодействия вида «запрос/
асинхронный ответ». Чтобы создать заказ, он отправляет сообщение с запросом
сервису Order. Затем этот сервис асинхронно обменивается сообщениями с другими
сервисами и в итоге возвращает клиенту ответ (рис. 3.16).
Рис. 3.16. Приложение FTGO окажется более доступным, если его сервисы будут общаться
с помощью асинхронных сообщений вместо синхронных вызовов
Клиент и сервис общаются асинхронно, отправляя сообщения через каналы.
Ни один из участников этого взаимодействия не блокируется в ожидании ответа.
Такая архитектура была бы чрезвычайно устойчивой, потому что брокер буфери
зирует сообщения до тех пор, пока их потребление не станет возможным. Но пробле
ма в том, что у сервисов часто есть внешний API, который использует синхронный
протокол вроде REST и, как следствие, обязан немедленно отвечать на запросы.
Если у сервиса есть синхронный API, доступность можно улучшить за счет ре
пликации данных. Посмотрим, как это работает.
142 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Репликация данных
Одним из способов минимизации синхронного взаимодействия во время обработки
запросов является репликация данных. Сервис хранит копию (реплику) данных,
которые ему нужны для обработки запросов. Чтобы поддерживать реплику в акту
альном состоянии, он подписывается на события, публикуемые сервисами, которым
эти данные принадлежат. Например, сервис Order может хранить копию данных,
принадлежащих сервисам Consumer и Restaurant. Это позволит ему обрабатывать
запросы на создание заказов, не обращаясь к этим сервисам. Такая архитектура по
казана на рис. 3.17.
Рис. 3.17. Сервис Order автономный, потому что у него есть копии данных о заказчиках
и ресторанах
Сервисы Consumer и Restaurant публикуют события всякий раз, когда их данные
меняются. Сервис Order подписывается на эти события и обновляет свою реплику.
В некоторых случаях репликация данных — это хорошее решение. Например,
в главе 5 описывается, как сервис Order реплицирует данные сервиса Restaurant,
чтобы иметь возможность проверять элементы меню. Один из недостатков этого
подхода связан с тем, что иногда он требует копирования больших объемов данных,
что неэффективно. Например, если у нас много заказчиков, хранить реплику данных,
принадлежащих сервису Consumer, может оказаться непрактично. Еще один недо
статок репликации кроется в том, что она не решает проблему обновления данных,
принадлежащих другим сервисам.
Чтобы решить эту проблему, сервис может отсрочить взаимодействие с други
ми сервисами до тех пор, пока он не ответит своему клиенту. Речь об этом пойдет
далее.
3.4. Использование асинхронного обмена сообщениями 143
Завершение обработки после возвращения ответа
Еще один способ устранения синхронного взаимодействия во время обработки
запросов состоит в том, чтобы выполнять эту обработку в виде следующих этапов.
1. Сервис проверяет запрос только с помощью данных, доступных локально.
2. Он обновляет свою базу данных, в том числе добавляет сообщения в таблицу OUTBOX.
3. Возвращает ответ своему клиенту.
Во время обработки запроса сервис не обращается синхронно ни к каким другим
сервисам. Вместо этого он шлет им асинхронные сообщения. Данный подход обе
спечивает слабую связанность сервисов. Как вы увидите в следующей главе, этот
процесс часто реализуется в виде повествования.
Представьте, что сервис Order действует таким образом. Он создает заказ с со
стоянием PENDING и затем проверяет его, обмениваясь асинхронными сообщениями
с другими сервисами. На рис. 3.18 показано, что происходит при вызове операции
createOrder(). Цепочка событий выглядит так.
1. Сервис Order создает заказ с состоянием PENDING.
2. Сервис Order возвращает своему клиенту ответ с ID заказа.
3. Сервис Order шлет сообщение ValidateConsumerlnfo сервису Consumer.
4. Сервис Order шлет сообщение ValidateOrderDetails сервису Restaurant.
5. Сервис Consumer получает сообщение ValidateConsumerlnfo, проверяет, может ли
заказчик размещать заказ, и отправляет сообщение ConsumerValidated сервису
Order.
6. Сервис Restaurant получает сообщение ValidateOrderDetails, проверяет кор
ректность элементов меню и способность ресторана доставить заказ по заданному
адресу и отправляет сообщение OrderDetailsValidated сервису Order.
7. Сервис Order получает сообщения ConsumerValidated и OrderDetailsValidated
и меняет состояние заказа на VALIDATED.
И так далее...
Сервис Order может получить сообщения ConsumerValidated и OrderDetailsVa
lidated в любом порядке. Чтобы знать, какое из них он получил первым, он меняет
состояние заказа. Если первым пришло сообщение ConsumerValidated, состояние
заказа меняется на CONSUMER-VALIDATED, а если OrderDetailsValidated — на ORDER_
DETAILS-VALIDATED. Получив второе сообщение, сервис Order присваивает заказу
состояние VALIDATED.
После проверки заказа сервис Order выполняет оставшиеся шаги по его созда
нию, о которых мы поговорим в следующей главе. Замечательной стороной этого
подхода является то, что сервис Order сможет создать заказ и ответить клиенту,
даже если сервис Consumer окажется недоступным. Рано или поздно сервис Consumer
восстановится и обработает все отложенные сообщения, что позволит завершить
проверку заказов.
144 Глава 3 • Межпроцессное взаимодействие в микросервисной архитектуре
Рис. 3.18. Сервис Order создает заказ, не вызывая ни один из прочих сервисов. Затем он
асинхронно проверяет только что созданный заказ, обмениваясь сообщениями с другими
сервисами, Consumer и Restaurant
Недостаток возвращения ответа до полной обработки запроса связан с тем, что
это делает клиент более сложным. Например, когда сервис Order возвращает ответ,
он дает минимальные гарантии по поводу состояния только что созданного заказа.
Он отвечает немедленно, еще до проверки заказа и авторизации банковской карты
клиента. Таким образом, чтобы узнать о том, успешно ли создан заказ, клиент дол
жен периодически запрашивать информацию или же сервис Order должен послать
ему уведомительное сообщение. Несмотря на всю сложность этого подхода, во мно
гих случаях стоит предпочесть его, особенно из-за того, что он учитывает проблемы
с управлением распределенными транзакциями, которые мы обсудим в главе 4.
В главах 4 и 5 я продемонстрирую эту методику на примере сервиса Order.
Резюме
□ Микросервисная архитектура является распределенной, поэтому межпроцессное
взаимодействие играет в ней ключевую роль.
□ К развитию API сервиса необходимо подходить тщательно и осторожно. Легче
всего вносить обратно совместимые изменения, поскольку они не влияют на ра
боту клиентов. При внесении ломающих изменений в API сервиса обычно прихо
дится поддерживать как старую, так и новую версию, пока клиенты не обновятся.
Резюме 145
□ Существует множество технологий IPC, каждая со своими достоинствами и не
достатками. Ключевое решение на стадии проектирования — выбор между син
хронным удаленным вызовом процедур и асинхронными сообщениями. Самыми
простыми в использовании являются синхронные протоколы вроде REST,
основанные на вызове удаленных процедур. Но в идеале, чтобы повысить уро
вень доступности, сервисы должны взаимодействовать с помощью асинхронного
обмена сообщениями.
□ Чтобы предотвратить лавинообразное накопление сбоев в системе, клиент,
использующий синхронный протокол, должен быть способен справиться с ча
стичными отказами — тем, что вызываемый сервис либо недоступен, либо про
являет высокую латентность. В частности, при выполнении запросов следует
отсчитывать время ожидания, ограничивать количество просроченных запросов
и применять шаблон «Предохранитель», чтобы избежать обращений к неисправ
ному сервису.
□ Архитектура, использующая синхронные протоколы, должна содержать меха
низм обнаружения, чтобы клиенты могли определить сетевое местонахождение
экземпляров сервиса. Проще всего остановиться на механизме обнаружения,
который предоставляет платформа развертывания: на шаблонах «Обнаружение на
стороне сервера» и «Сторонняя регистрация». Альтернативный подход — реали
зация обнаружения сервисов на уровне приложения: шаблоны «Обнаружение на
стороне клиента» и «Саморегистрация». Этот способ требует больших усилий, но
подходит для ситуаций, когда сервисы выполняются на нескольких платформах
развертывания.
□ Модель сообщений и каналов инкапсулирует детали реализации системы обмена
сообщениями и становится хорошим выбором при проектировании архитектуры
этого вида. Позже вы сможете привязать свою архитектуру к конкретной инфра
структуре обмена сообщениями, в которой обычно используется брокер.
□ Ключевая трудность при обмене сообщениями связана с их публикацией и об
новлением базы данных. Удачным решением является применение шаблона
«Публикация событий»: сообщение в самом начале записывается в базу данных
в рамках транзакции. Затем отдельный процесс извлекает сообщение из базы
данных, используя шаблон «Опрашивающий издатель» или «Отслеживание
транзакционного журнала», и передает его брокеру.
Управление транзакциями
с помощью повествований
Когда Мэри начала исследовать микросервисную архитектуру, то поняла, что один
из аспектов, которые беспокоили ее больше всего, связан с реализацией транзакций,
охватывающих несколько сервисов. Транзакции — незаменимый компонент любого
промышленного приложения. Без них невозможно поддерживать согласованность
данных.
Транзакции типа ACID (Atomicity, Consistency, Isolation, Durability — «атомарность,
согласованность, изолированность, долговечность») значительно упрощают жизнь
разработчиков, создавая иллюзию того, что каждая из них имеет эксклюзивный доступ
к данным. В микросервисной архитектуре ACID-транзакции могут использовать даже
запросы, выполняемые в рамках одного сервиса. Однако основная трудность состоит
в реализации операций, которые обновляют данные, принадлежащие разным сервисам.
Например, как упоминалось в главе 2, операция createOrder() охватывает множество
сервисов, включая Order, Kitchen и Accounting. Для подобных операций нужен меха
низм управления транзакциями, который не ограничен отдельным сервисом.
Мэри обнаружила, что традиционный подход к управлению распределенными
транзакциями не очень подходит для современных приложений (об этом говорилось
4.1. Управление транзакциями в микросервисной архитектуре 147
в главе 2). Вместо ACID-транзакций операция, охватывающая несколько сервисов
и стремящаяся поддерживать согласованность данных, должна использовать то, что
называется повествованием (или сагой), — последовательность локальных транзакций
на основе сообщений. Одна из проблем повествований связана с тем, что по своей
природе они являются ACD (Atomicity, Consistency, Durability — «атомарность, со
гласованность, долговечность»). Им не хватает поддержки изолированности, которая
есть в ACID-транзакциях. В итоге приложение должно использовать так называемые
контрмеры — методики проектирования, которые устраняют или снижают влияние
аномалий конкурентности, вызванных нехваткой изолированности.
Скорее всего, самым большим препятствием, с которым столкнутся Мэри и раз
работчики FTGO при внедрении микросервисов, будет переход от единой базы
данных с ACID-транзакциями к архитектуре с множеством баз данных и работе
с ACD-повествованиями. Они привыкли к простоте модели ACID-транзакций. Но
в реальности даже такие монолитные приложения, как FTGO, не используют клас
сические ACID-транзакции. Во многих проектах установлен пониженный уровень
изолированности, чтобы улучшить производительность. Кроме того, множество важ
ных бизнес-процессов, таких как перевод средств между счетами в разных банках,
имеют отложенную согласованность. Даже в Starbucks не задействуется двухэтапная
фиксация (www.enterpriseintegrationpatterns.com/ramblings/18_starbucks.html).
Я начну эту главу с рассмотрения трудностей в работе с транзакциями в микро
сервисной архитектуре и объясню, почему здесь не годится традиционный подход
к распределенным транзакциям. Далее расскажу, как поддерживать согласованность
данных с помощью повествований. После этого мы поговорим о двух способах ко
ординации повествований: хореографии, когда участники обмениваются событиями
без централизованной точки управления, и оркестрации, когда централизованный
контроллер говорит участникам повествования, какие операции нужно выполнить.
Я покажу, как использовать контрмеры, чтобы устранить или снизить влияние ано
малий конкурентности, вызванных нехваткой изолированности между повествова
ниями. В конце будет представлен пример реализации повествования.
Для начала посмотрим, какие трудности сопровождают работу с транзакциями
в микросервисной архитектуре.
4.1. Управление транзакциями
в микросервисной архитектуре
Почти любой запрос, обрабатываемый промышленным приложением, выполняется
в рамках транзакции базы данных. Разработчики таких приложений используют
фреймворки и библиотеки, которые упрощают работу с транзакциями. Некото
рые инструменты предоставляют императивный API для выполняемого вручную
начала, фиксации и отката транзакций. А такие фреймворки, как Spring, имеют де
кларативный механизм. Spring поддерживает аннотацию ^Transactional, которая
автоматически вызывает метод в рамках транзакции. Благодаря этому написание
транзакционной бизнес-логики становится довольно простым.
148 Глава 4 • Управление транзакциями с помощью повествований
Если быть более точным, управлять транзакциями просто в монолитных прило
жениях, которые обращаются к единой базе данных. Если же приложение задейству
ет несколько БД и брокеров сообщений, этот процесс затрудняется. Ну а в микро
сервисной архитектуре транзакции охватывают несколько сервисов, каждый из
которых имеет свою БД. В таких условиях приложение должно использовать более
продуманный механизм работы с транзакциями. Как вы вскоре увидите, традици
онный подход к распределенным транзакциям нежизнеспособен в современных
приложениях. Вместо него системы на основе микросервисов должны применять
повествования.
Но прежде, чем переходить к повествованиям, посмотрим, почему управление
транзакциями создает столько сложностей в микросервисной архитектуре.
4.1.1. Микросервисная архитектура и необходимость
в распределенных транзакциях
Представьте, что вы — разработчик в компании FTGO и отвечаете за реализацию
системной операции createOrder(). Как было написано в главе 2, эта операция
должна убедиться в том, что заказчик может размещать заказы, проверить детали
заказа, авторизовать банковскую карту заказчика и создать запись Order в базе
данных. Реализация этих действий была бы относительно простой в монолитном
приложении. Все данные, необходимые для проверки заказа, уже готовы и доступны.
Кроме того, для обеспечения согласованности данных можно было бы использовать
ACID-транзакции. Вы могли бы просто указать аннотацию ^Transactional для ме
тода сервиса createOrder().
Однако выполнить эту операцию в микросервисной архитектуре гораздо слож
нее. Как видно на рис. 4.1, данные, необходимые операции createOrder(), разбросаны
по нескольким сервисам. createOrder() считывает информацию из сервиса Consumer
и обновляет содержимое сервисов Order, Kitchen и Accounting.
Поскольку у каждого сервиса есть своя БД, вы должны использовать механизм
для согласования данных между ними.
4.1.2. Проблемы с распределенными транзакциями
Традиционный подход к обеспечению согласованности данных между несколькими
сервисами, БД или брокерами сообщений заключается в применении распределенных
транзакций. Стандартом де-факто для управления распределенными транзакциями
является Х/Ореп ХА (см. https://ru.wikipedia.org/wiki/XA). Модель ХА использует двух
этапную фиксацию (two-phase commit, 2РС), чтобы гарантировать сохранение или
откат всех изменений в транзакции. Для этого требуется, чтобы базы данных, брокеры
сообщений, драйверы БД и API обмена сообщениями соответствовали стандарту ХА,
необходим также механизм межпроцессного взаимодействия, который распространяет
глобальные идентификаторы ХА-транзакций. Большинство реляционных БД совме
стимы с ХА, равно как и некоторые брокеры сообщений. Например, приложение на
основе Java ЕЕ может выполнять распределенные транзакции с помощью JTA.
4.1. Управление транзакциями в микросервисной архитектуре 149
Рис. 4.1. Операция createOrder() обновляет данные в нескольких сервисах. Чтобы обеспечить
согласованность между ними, она должна задействовать специальный механизм
Несмотря на внешнюю простоту, распределенные транзакции имеют ряд про
блем. Многие современные технологии, включая такие базы данных NoSQL, как
MongoDB и Cassandra, их не поддерживают. Распределенные транзакции не под
держиваются и некоторыми современными брокерами сообщений вроде RabbitMQ
и Apache Kafka. Так что, если вы решите использовать распределенные транзакции,
многие современные инструменты будут вам недоступны.
Еще одна проблема распределенных транзакций связана с тем, что они представ
ляют собой разновидность синхронного IPC, а это ухудшает доступность. Чтобы
распределенную транзакцию можно было зафиксировать, доступными должны быть
все вовлеченные в нее сервисы. Как описывалось в главе 3, доступность системы —
это произведение доступности всех участников транзакции. Если в распределенной
транзакции участвуют два сервиса с доступностью 99,5 %, общая доступность будет
99 %, что намного меньше. Каждый дополнительный сервис понижает степень до
ступности. Эрик Брюер (Eric Brewer) сформулировал САР-теорему, которая гласит,
150 Глава 4 • Управление транзакциями с помощью повествований
что система может обладать лишь двумя из следующих трех свойств: согласован
ность, доступность и устойчивость к разделению (ru.wikipedia.org/wiki/TeopeMa_CAP).
В наши дни архитекторы отдают предпочтение доступным системам, жертвуя со
гласованностью.
На первый взгляд распределенные транзакции могут показаться привлека
тельными. С точки зрения разработчика, они имеют ту же программную модель,
что и локальные транзакции. Но из-за проблем, описанных ранее, эта технология
оказывается нежизнеспособной в современных приложениях. В главе 3 было по
казано, как отправлять сообщения в рамках транзакции базы данных, не используя
при этом распределенные транзакции. Для решения более сложной проблемы,
связанной с обеспечением согласованности данных в микросервисной архитектуре,
приложение должно применять другой механизм, основанный на концепции слабо
связанных асинхронных сервисов. И здесь пригодятся повествования.
4.1.3. Использование шаблона «Повествование»
для сохранения согласованности данных
Повествования — это механизм, обеспечивающий согласованность данных в микро
сервисной архитектуре без применения распределенных транзакций. Повествова
ние создается для каждой системной команды, которой нужно обновлять данные
в нескольких сервисах. Это последовательность локальных транзакций, каждая
из которых обновляет данные в одном сервисе, задействуя знакомые фреймворки
и библиотеки для ACID-транзакций, упомянутые ранее.
Системная операция инициирует первый этап повествования. Завершение одной
локальной транзакции приводит к выполнению следующей. В разделе 4.2 вы увиди
те, как координация этих этапов реализуется с помощью асинхронных сообщений.
Важным преимуществом асинхронного обмена сообщениями является то, что он
гарантирует выполнение всех этапов повествования, даже если один или несколько
участников оказываются недоступными.
Повествования имеют несколько важных отличий от ACID-транзакций. Прежде
всего, им не хватает изолированности (подробно об этом — в разделе 4.3). К тому
же, поскольку каждая локальная транзакция фиксирует свои изменения, для отката
повествования необходимо использовать компенсирующие транзакции, о которых
мы поговорим позже в этом разделе. Рассмотрим пример повествования.
4.1. Управление транзакциями в микросервисной архитектуре 151
Пример повествования: создание заказа
В этой главе в качестве примера используем повествование Create Order (рис. 4.2).
Оно реализует операцию createOrder(). Первая локальная транзакция иницииру
ется внешним запросом создания заказа. Остальные пять транзакций срабатывают
одна за другой.
Рис. 4.2. Создание заказа с помощью повествования. Операция createOrderQ реализуется
повествованием, которое состоит из локальных транзакций в нескольких сервисах
Это повествование состоит из следующих локальных транзакций.
1. Сервис Order. Создает заказ с состоянием APPROVAL-PENDING.
2. Сервис Consumer. Проверяет, может ли заказчик размещать заказы.
3. Сервис Kitchen. Проверяет детали заказа и создает заявку с состоянием CREATE-
PENDING.
4. Сервис Accounting. Авторизует банковскую карту заказчика.
5. Сервис Kitchen. Меняет состояние заявки на AWAITING_ACCEPTANCE.
6. Сервис Order. Меняет состояние заказа на APPROVED.
В разделе 4.2 я покажу, как сервисы, участвующие в повествовании, взаимодейству
ют между собой с помощью асинхронных сообщений. Сервис публикует сообщение по
завершении локальной транзакции. Это инициирует следующий этап повествования
и позволяет не только добиться слабой связанности участников, но и гарантировать
полное выполнение повествования. Даже если получатель временно недоступен, брокер
буферизирует сообщение до того момента, когда его можно будет доставить.
Повествования выглядят простыми, но их использование связано с некоторыми
трудностями, прежде всего с нехваткой изолированности между ними. Решение
проблемы описано в разделе 4.3. Еще один нетривиальный аспект связан с откатом
изменений при возникновении ошибки. Посмотрим, как это делается.
152 Глава 4 • Управление транзакциями с помощью повествований
Повествования применяют компенсирующие транзакции
для отката изменений
У традиционных ACID-транзакций есть одно прекрасное свойство: бизнес-логика
может легко откатить транзакцию, если обнаружится нарушение бизнес-правила.
Она просто выполняет команду ROLLBACK, а база данных отменяет все изменения,
внесенные до этого момента. К сожалению, повествование нельзя откатить авто
матически, поскольку на каждом этапе оно фиксирует изменения в локальной базе
данных. Это, к примеру, означает, что в случае неудачной авторизации банковской
карты на четвертом этапе повествования Create Order приложение FTGO должно
вручную отменить изменения, сделанные на предыдущих трех этапах. Вы должны
написать так называемые компенсирующие транзакции.
Допустим, (п + 1)-я транзакция в повествовании завершилась неудачно. Необ
ходимо нивелировать последствия от предыдущих п транзакций. На концептуаль
ном уровне каждый из этих этапов Г имеет свою компенсирующую транзакцию С,
которая отменяет эффект от Г. Чтобы компенсировать эффект от первых п этапов,
повествование должно выполнить каждую транзакцию С в обратном порядке.
Последовательность выглядит так: ... Тп, Сп... (рис. 4.3). В данном примере от
казывает этап Tn + V что требует отмены шагов 7\... Тп.
Рис. 4.3. Когда этап повествования завершается неудачей в результате нарушения
бизнес-правила, повествование должно вручную отменить все обновления, сделанные
на предыдущих этапах, выполнив компенсирующие транзакции
Повествование выполняет компенсирующие транзакции в обратном порядке по
отношению к исходным: Сп... Cv Здесь действует тот же механизм последовательного
выполнения, что и в случае с Г. Завершение С. должно инициировать С г
Возьмем, к примеру, повествование Create Order. Оно может отказать по целому
ряду причин.
1. Некорректная информация о заказчике, или заказчику не позволено создавать
заказы.
4.1. Управление транзакциями в микросервисной архитектуре 153
2. Некорректная информация о ресторане, или ресторан не в состоянии принять
заказ.
3. Невозможность авторизовать банковскую карту заказчика.
В случае сбоя в локальной транзакции механизм координации повествования
должен выполнить компенсирующие шаги, которые отклоняют заказ и, возможно,
заявку. В табл. 4.1 собраны компенсирующие транзакции для каждого этапа по
вествования Create Order. Следует отметить, что не всякий этап требует компен
сирующей транзакции. Это относится, например, к операциям чтения, таким как
verifyConsumerDetails(), или к операции authorizeCreditCard(), все шаги после
которой всегда завершаются успешно.
Таблица 4.1. Компенсирующие транзакции для повествования Create Order
Этап Сервис Транзакция Компенсирующая транзакция
1 Order createOrder() rejectOrder()
2 Consumer verify ConsumerDetails() -
3 Kitchen createTicket() rejectTicket()
4 Accounting authorizeCreditCard() -
5 Kitchen appro veTicket() -
6 Order approveOrder() -
В разделе 4.3 вы узнаете, что первые три этапа повествования Create Order на
зываются доступными для компенсации транзакциями, потому что шаги, следующие
за ними, могут отказать. Четвертый этап называется поворотной транзакцией, по
тому что дальнейшие шаги никогда не отказывают. Последние два этапа называются
доступными для повторения транзакциями, потому что они всегда заканчиваются
успешно.
Чтобы понять, как используются компенсирующие транзакции, представьте
ситуацию, в которой авторизация банковской карты заказчика проходит неудачно.
В этом случае повествование выполняет следующие локальные транзакции.
1. Сервис Order. Создает заказ с состоянием APPROVAL-PENDING.
2. Сервис Consumer. Проверяет, может ли заказчик размещать заказы.
3. Сервис Kitchen. Проверяет детали заказа и создает заявку с состоянием CREATE-
PENDING.
4. Сервис Accounting. Делает неудачную попытку авторизовать банковскую карту
заказчика.
5. Сервис Kitchen. Меняет состояние заявки на CREATE_REJECTED.
6. Сервис Order. Меняет состояние заказа на REJECTED.
154 Глава 4 • Управление транзакциями с помощью повествований
Пятый и шестой этапы — это компенсирующие транзакции, которые отменяют
обновления, внесенные сервисами Kitchen и соответственно Order. Координиру
ющая логика повествования отвечает за последовательность выполнения прямых
и компенсирующих транзакций. Посмотрим, как это работает.
4.2. Координация повествований
Реализация повествования состоит из логики, которая координирует его этапы.
Когда повествование инициируется системной командой, координирующая логика
должна выбрать первого участника и сделать так, чтобы тот выполнил локальную
транзакцию. Когда транзакция завершится, механизм координации выберет и вы
зовет следующего участника. Этот процесс продолжается до тех пор, пока повество
вание не выполнит все свои этапы. Если какая-либо локальная транзакция завер
шится неудачно, повествование должно выполнить компенсирующие транзакции
в обратном порядке. Координирующую логику можно структурировать следующими
способами.
□ Хореография — распределение принятия решений и упорядочения действий
между участниками повествования, которые в основном общаются, обмениваясь
событиями.
□ Оркестрация — централизация координирующей логики повествования в виде
класса-оркестратора. Оркестратор отправляет участникам повествования ко
мандные сообщения с инструкциями, какие операции нужно выполнить.
Обсудим оба варианта. Начнем с хореографии.
4.2.1. Повествования, основанные на хореографии
Хореография — это один из способов реализации повествований. Она не предусма
тривает центрального координатора, который выдает участникам команды. Вместо
этого участники подписываются на события друг друга и реагируют соответству
ющим образом. Чтобы показать, как работают повествования на основе хореографии,
я сначала опишу пример. После этого мы обсудим несколько архитектурных про
блем, которые вы должны учитывать. Затем будут представлены плюсы и минусы
использования хореографии.
Реализация повествования Create Order
с помощью хореографии
На рис. 4.4 представлена архитектура повествования Create Order, основанного на
хореографии. Участники взаимодействуют, обмениваясь сообщениями. Каждый
участник, начиная с сервиса Order, обновляет свою базу данных и публикует собы
тие, благодаря которому срабатывает следующий участник.
4.2. Координация повествований 155
Р
и
с.
4
.4
. Р
еа
ли
за
ци
я
по
ве
ст
во
ва
ни
я
Cr
ea
te
O
rd
er
с
п
ом
ощ
ью
х
ор
ео
гр
аф
ии
. У
ча
ст
ни
ки
п
ов
ес
тв
ов
ан
ия
о
бщ
аю
тс
я,
о
бм
ен
ив
ая
сь
с
об
ы
ти
ям
и
156 Глава 4 • Управление транзакциями с помощью повествований
Оптимистичный путь через это повествование выглядит так.
1. Сервис Order создает заказ с состоянием APPROVAL_PENDING и публикует событие
OrderCreated.
2. Сервис Consumer потребляет событие OrderCreated, проверяет, может ли заказчик
размещать заказы, и публикует событие ConsumerVerif ied.
3. Сервис Kitchen потребляет событие OrderCreated, проверяет заказ, создает заявку
с состоянием CREATE_PENDING и публикует событие TicketCreated.
4. Сервис Accounting потребляет событие OrderCreated и создает объект Сге-
ditCardAuthorization с состоянием PENDING.
5. Сервис Accounting потребляет события TicketCreated и ConsumerVerif ied, выстав
ляет счет банковской карте заказчика и публикует событие CreditCardAuthorized.
6. Сервис Kitchen потребляет событие CreditCardAuthorized и меняет состояние
заявки на AWAITING_ACCEPTANCE.
7. Сервис Order принимает события CreditCardAuthorized, меняет состояние заказа
на APPROVED и публикует событие OrderApproved.
Повествование Create Order должно также предусматривать сценарий, в котором
участник отклоняет заказ и публикует некое неудачное событие. Например, неуда
чей может завершиться авторизация банковской карты заказчика. Повествование
должно выполнить компенсирующие транзакции, чтобы отменить то, что уже было
сделано. На рис. 4.5 показана последовательность событий, когда сервису Accounting
не удается авторизовать банковскую карту заказчика. Это выглядит так.
1. Сервис Order создает заказ с состоянием APPROVAL_PENDING и публикует событие
OrderCreated.
2. Сервис Consumer потребляет событие OrderCreated, проверяет, может ли заказчик
размещать заказы, и публикует событие ConsumerVerif ied.
3. Сервис Kitchen потребляет событие OrderCreated, проверяет заказ, создает заявку
с состоянием CREATE_PENDING и публикует событие TicketCreated.
4. Сервис Accounting потребляет событие OrderCreated и создает объект Сге-
ditCardAuthorization с состоянием PENDING.
5. Сервис Accounting потребляет события TicketCreated и ConsumerVerified, вы
ставляет счет банковской карте заказчика и публикует событие CreditCardAutho-
rizationFailed.
6. Сервис Kitchen потребляет событие CreditCardAuthorizationFailed и меняет
состояние заявки на REJECTED.
7. Сервис Order потребляет событие CreditCardAuthorizationFailed и меняет со
стояние заказа на REJECTED.
Как видите, участники повествования, основанного на хореографии, взаимодей
ствуют в стиле «издатель/подписчик». Давайте подробнее поговорим о некоторых
проблемах, которые следует учитывать при реализации взаимодействия вида «из
датель/подписчик» в ваших повествованиях.
4.2. Координация повествований 157
158 Глава 4 • Управление транзакциями с помощью повествований
Надежное взаимодействие на основе событий
У межсервисного взаимодействия есть несколько проблем, которые необходимо
учитывать при реализации повествований на основе хореографии. Во-первых,
следует сделать так, чтобы участники повествования обновляли свои базы данных
и публиковали события в рамках транзакций БД. Каждый этап повествования,
основанного на хореографии, обновляет базу данных и публикует событие. Напри
мер, в Create Order сервис Kitchen получает событие ConsumerVerified, создает
заявку и публикует событие TicketCreated. Крайне важно, чтобы обновление БД
и публикация события были атомарными. Следовательно, для надежного взаимо
действия участники повествования должны использовать транзакционный обмен
сообщениями, описанный в главе 3.
Во-вторых, участники повествования должны иметь возможность сопоставить
каждое событие, которое они принимают, с собственными данными. Например,
когда сервис Order получает событие CreditCardAuthorized, он должен уметь найти
соответствующий заказ. Решением здесь является публикация событий с иденти
фикаторами соответствия, благодаря которым другие участники могут выполнить
сопоставление.
Например, участники повествования Create Order могут использовать параметр
orderld в качестве ID соответствия, он будет передаваться от одного участника
к другому. Сервис Accounting публикует событие CreditCardAuthorized с orderld из
события TicketCreated. Когда сервис Order получает событие CreditCardAuthorized,
он задействует orderld для извлечения соответствующего заказа. Аналогичным
образом сервис Kitchen задействует orderld из того же события, чтобы извлечь
подходящую заявку.
Преимущества и недостатки повествований
на основе хореографии
У повествований, основанных на хореографии, есть несколько преимуществ.
□ Простота. Сервисы публикуют события при создании, обновлении и удалении
бизнес-объектов.
□ Слабая связанность. Участники подписываются на события, не владея непосред
ственной информацией друг о друге.
Но существуют и определенные недостатки.
□ Они сложнее для понимания. В отличие от оркестрации хореография не описыва
ет повествование на каком-то одном участке кода — его реализация разбросана
между сервисами. Из-за этого разработчикам иногда трудно понять, как работает
то или иное повествование.
□ Возникают циклические зависимости между сервисами. Участники повество
вания подписываются на события друг друга, что часто создает циклические
зависимости. Например, если внимательно присмотреться к рис. 4.4, можно за-
4.2, Координация повествований 159
метить такие циклические зависимости, как Order Accounting -> Order. Они
не всегда являются проблемой, но, как принято считать, их наличие — признак
плохого тона.
□ Существует риск жесткого связывания. Каждый участник повествования дол
жен подписаться на все события, которые на него влияют. Например, сервис
Accounting интересуют все события, приводящие к выставлению счета или воз
мещению средств на банковской карте. В итоге возникает риск того, что ему при
дется обновляться синхронно с жизненным циклом заказа, который реализован
сервисом Order.
Хореография может хорошо работать с простыми повествованиями, но, учитывая
ее недостатки, в более сложных случаях лучше использовать оркестрацию. Об этом
мы поговорим далее.
4.2.2. Повествования на основе оркестрации
Оркестрация — это еще один способ реализации повествований. Она подразумевает
определение класса-оркестратора, единственной задачей которого является рассыл
ка инструкций участникам. Оркестратор взаимодействует с участниками в стиле
«команда/асинхронный ответ». Чтобы выполнить этап повествования, он шлет
участнику командное сообщение, объясняя, какую операцию тот должен выполнить.
После выполнения операции участник возвращает оркестратору сообщение с отве
том. Оркестратор обрабатывает это сообщение и решает, какой этап повествования
нужно выполнить дальше.
Чтобы показать, как работают повествования на основе оркестрации, я сначала
опишу пример. Затем покажу, как моделировать такие повествования в виде конеч
ного автомата. И объясню, как обеспечить надежное взаимодействие между орке
стратором и участниками с помощью транзакционного обмена сообщениями. В кон
це мы обсудим преимущества и недостатки повествований на основе оркестрации.
Реализация повествования Create Order
с помощью оркестрации
Архитектура повествования Create Order, основанного на оркестрации, представлена
на рис. 4.6. Повествование управляется с помощью класса CreateOrderSaga, который
общается с участниками с помощью асинхронных запросов/ответов. Этот класс
отслеживает весь процесс и шлет командные сообщения участникам, таким как
сервисы Kitchen и Consumer. Класс CreateOrderSaga читает сообщения из канала
с ответами и определяет следующий шаг повествования (если таковой имеется).
Вначале сервис Order создает заказ и оркестратор повествования Create Order.
После этого оптимистичный путь выглядит так.
1. Оркестратор повествования отправляет сервису Consumer команду VerifyConsumer.
2. Сервис Consumer возвращает в ответ сообщение ConsumerVerified.
160 Глава 4 • Управление транзакциями с помощью повествований
3. Оркестратор отправляет сервису Kitchen команду CreateTicket.
4. Сервис Kitchen возвращает в ответ сообщение TicketCreated.
5. Оркестратор отправляет сервису Accounting сообщение AuthorizeCard.
6. Сервис Accounting возвращает в ответ сообщение CardAuthorized.
7. Оркестратор отправляет сервису Kitchen команду ApproveTicket.
8. Оркестратор отправляет сервису Order команду ApproveOrder.
Рис. 4.6. Реализация повествования Create Order с помощью оркестрации. Сервис Order
реализует оркестратор, который вызывает участников повествования с помощью асинхронных
запросов/ответов
Обратите внимание на то, что на последнем этапе оркестратор шлет командное
сообщение сервису Order, компонентом которого он сам является. В принципе,
повествование Create Order могло бы подтвердить заказ, обновив его напрямую.
Но, чтобы оставаться последовательным, оно обращается с сервисом Order просто
как с еще одним участником.
4.2. Координация повествований 161
Такие схемы, как на рис. 4.6, описывают лишь один из множества потенциальных
сценариев повествования. Например, у повествования Create Order есть четыре
сценария. Помимо оптимистичного пути, процесс может завершиться неудачно
из-за сбоя в сервисах Consumer, Kitchen или Accounting. В связи с этим имеет смысл
смоделировать повествование в виде конечного автомата, который описывает все
возможные сценарии.
Моделирование оркестраторов повествований
в виде конечных автоматов
Конечный автомат — это хорошая модель для оркестратора повествования. Он со
стоит из набора состояний и переходов между ними, которые инициируются с по
мощью событий. У каждого перехода может быть какое-то действие, которое в кон
тексте повествования означает вызов участника. Переходы между состояниями
инициируются завершением локального перехода, выполненного участником по
вествования. Текущее состояние и конкретный результат локального перехода
определяют последующий переход и действие, которое нужно выполнить (если
таковое имеется). Конечный автомат имеет эффективные стратегии тестирования.
Благодаря этому использование данной модели упрощает проектирование, реали
зацию и тестирование повествований.
На рис. 4.7 показана модель конечного автомата для повествования Create Order.
Она включает в себя следующие состояния.
□ Проверка заказчика — начальное состояние. Повествование ждет, когда сервис
Consumer подтвердит, что заказчик может размещать заказы.
□ Создание заявки — повествование ждет ответа на команду CreateTicket.
□ Авторизация карты — ожидание авторизации банковской карты заказчика сер
висом Accounting.
□ Заказ подтвержден — финальный этап, свидетельствующий об успешном за
вершении повествования.
□ Заказ отклонен — финальный этап, свидетельствующий об отклонении заказа
одним из участников.
Конечный автомат также определяет множество переходов состояний. Например,
состояние создание заявки может перейти в одно из двух состояний: авторизация
карты или заказ отклонен. В первом случае нужно получить успешный ответ на
команду CreateTicket, а во втором сервису Kitchen не удается создать заявку.
В самом начале конечный автомат отправляет команду VerifyConsumer сервису
Consumer. Ответ от этого сервиса инициирует переход к следующему состоянию.
Если заказчик успешно прошел проверку, повествование создает заявку и переходит
к состоянию «создание заявки». Но если проверка завершается неудачно, повество
вание отклоняет заказ и переходит к состоянию «отклонение заказа». Конечный
автомат выполняет множество других переходов, которые инициируются ответами
участников повествования, пока не дойдет до финального состояния: «заказ под
твержден» или «заказ отклонен».
162 Глава 4 • Управление транзакциями с помощью повествований
Рис. 4.7. Модель конечного автомата для повествования Create Order
Оркестрация повествований
и транзакционный обмен сообщениями
На каждом этапе повествования, основанного на оркестрации, какой-то сервис
обновляет базу данных и публикует сообщение. Например, сервис Order сохраняет
заказ, а оркестратор шлет сообщение первому участнику повествования. Участник
(например, сервис Kitchen) обрабатывает команду, обновляя свою базу данных и от
правляя ответное сообщение. Сервис Order обрабатывает ответ участника, обновляя
4.2. Координация повествований 163
состояние оркестратора и отправляя командное сообщение следующему участнику.
Как описано в главе 3, для атомарного обновления БД и публикации сообщений
сервис должен использовать транзакции. В разделе 4.4 вы подробнее познакомитесь
с реализацией оркестратора повествования Create Order, в том числе с тем, как в нем
применяется транзакционный обмен сообщениями.
Рассмотрим преимущества и недостатки использования оркестрации в пове
ствованиях.
Преимущества и недостатки повествований,
основанных на оркестрации
Повествования, основанные на оркестрации, имеют несколько преимуществ.
□ Упрощенные зависимости. Одной из положительных сторон оркестрации явля
ется то, что она не создает циклических зависимостей. Оркестратор вызывает
участников повествования, но участники не вызывают оркестратор. В резуль
тате оркестратор зависит от участников, но не наоборот, поэтому циклических
зависимостей нет.
□ Меньше связывания. Каждый сервис реализует API, который вызывается ор
кестратором, поэтому ему не нужно знать о событиях, публикуемых другими
участниками повествования.
□ Улучшенное разделение ответственности и упрощенная бизнес-логика. Вся коор
динирующая логика повествования находится в оркестраторе. Благодаря этому
доменные объекты становятся проще и им не нужно знать о повествованиях,
в которых они участвуют. Например, при использовании оркестрации класс Order
ничего не знает о повествованиях, поэтому имеет более простую модель конеч
ного автомата. Во время выполнения повествования Create Order он переходит
напрямую из состояния APPROVAL-PENDING в состояние APPROVED. Класс Order
не обладает никакими промежуточными состояниями, которые соответствуют
этапам повествования. Это значительно упрощает бизнес-логику.
При этом оркестрация имеет один недостаток — риск избыточной централизации
бизнес-логики в оркестраторе. В результате получается архитектура, в которой ум
ный оркестратор командует глупыми сервисами. К счастью, этой проблемы можно
избежать, если проектировать оркестраторы так, чтобы они отвечали лишь за по
следовательное выполнение действий и не содержали никакой дополнительной
бизнес-логики.
Я рекомендую использовать оркестрацию для любых повествований, за исклю
чением самых простых. Реализация координирующей логики для повествований —
это лишь одна из задач проектирования, которые вы должны решить. Еще одной
проблемой, которая наверняка создаст вам больше всего трудностей в ходе работы
с повествованиями, является нехватка изолированности. Давайте рассмотрим ее
и подумаем, как ее решить.
164 Глава 4 • Управление транзакциями с помощью повествований
4.3. Что делать с недостаточной
изолированностью
Буква I в аббревиатуре ACID означает isolation (изолированность). Это свойство га
рантирует, что результат параллельного выполнения нескольких ACID-транзакций
будет таким же, как при некоем последовательном выполнении. База данных дает
иллюзию того, что каждая ACID-транзакция имеет эксклюзивный доступ к ин
формации. Изолированность намного упрощает написание бизнес-логики, которая
выполняется конкурентно.
Трудность в ходе работы с повествованиями состоит в том, что им не хватает изо
лированности ACID-транзакций. Дело тут в следующем: обновления, которые вы
полняет каждая локальная транзакция, сразу же фиксируются и становятся доступ
ными любым другим повествованиям. Такое поведение может создать две проблемы.
Во-первых, другие повествования могут изменить данные, к которым обращается
используемое в данный момент повествование. Во-вторых, другие повествования
могут читать данные в процессе их обновления, что чревато несогласованностью.
На самом деле можно считать, что повествования соответствуют принципу ACD.
□ Atomicity (атомарность) — реализация повествования гарантирует выполнение
или отмену всех транзакций.
□ Consistency (согласованность) — за ссылочную целостность внутри сервиса отве
чает локальная база данных, за ссылочную целостность между сервисами — сами
сервисы.
□ Durability (устойчивость) — обеспечивается локальной базой данных.
Нехватка изолированности может вызвать аномалии (этот термин встречается
в литературе по базам данных). Так называется ситуация, когда параллельное вы
полнение транзакций дает иные результаты, чем последовательное.
На первый взгляд отсутствие изолированности кажется неприемлемым. Но на
практике разработчики часто уменьшают изолированность для получения более вы
сокой производительности. СУ РБД позволяют выбрать уровень изолированности для
каждой транзакции (dev.mysql.com/doc/refman/5.7/en/innodb-transaction-isolation-levels.html).
Полная изолированность, известная также как сериализуемые транзакции, обычно
не используется по умолчанию. В реальном мире транзакции часто отклоняются от
книжных определений ACID.
В следующем разделе мы обсудим ряд стратегий проектирования повествований,
которые помогают справиться с нехваткой изолированности. Их также называют
контрмерами. Некоторые из них реализуют изолированность на уровне приложения.
Другие снижают бизнес-риски, связанные с ее отсутствием. Используя контрмеры,
вы сможете написать бизнес-логику на основе повествований, которая будет рабо
тать корректно.
Я начну с описания аномалий, вызванных нехваткой изолированности. После
этого мы поговорим о контрмерах, которые либо устраняют эти аномалии, либо
снижают их бизнес-риски.
4.3. Что делать с недостаточной изолированностью 165
4.3.1. Обзор аномалий
Нехватка изолированности может привести к трем аномалиям.
□ Потеря обновлений — одно повествование перезаписывает изменения, внесенные
другим, не читая их при этом.
□ «Грязное» чтение — транзакция или повествование читают незавершенные об
новления другого повествования.
□ Нечеткое/неповторяемое чтение — два разных этапа повествования читают одни
и те же данные, но получают разные результаты, потому что другое повествова
ние внесло изменения.
Вы можете столкнуться со всеми тремя аномалиями, но первые две самые рас
пространенные и вызывающие больше проблем. Рассмотрим их подробнее. Начнем
с потерянных обновлений.
Потерянные обновления
Эта аномалия возникает, когда повествование перезаписывает обновление, сделан
ное другим повествованием. Например, представьте себе такой сценарий.
1. Первый этап повествования Create Order создает заказ.
2. Пока это повествование выполняется, повествование Cancel Order отменяет заказ.
3. На завершающем этапе повествование Create Order подтверждает заказ.
В этой ситуации Create Order игнорирует и перезаписывает обновление, выполнен
ное повествованием Cancel Order. В итоге приложение FTGO отправит уже отменен
ный заказ. Позже в этом разделе я покажу, как предотвратить потерю обновлений.
«Грязное» чтение
«Грязным» называют чтение, которое происходит в процессе обновления данных
другим повествованием. Представьте, к примеру, разновидность приложения FTGO
с поддержкой кредитного лимита. В этом случае повествование, отменяющее заказ,
состоит из следующих транзакций:
□ сервис Consumer — увеличивает доступный кредит;
□ сервис Ordei— меняет состояние заказа на CANCELLED;
□ сервис Delivery — отменяет доставку.
Теперь представьте, что во время выполнения повествований Cancel Order
и Create Order первое откатывается, так как отменять доставку уже поздно. Есть ве
роятность того, что у нас получится следующая последовательность транзакций,
которые вызывают сервис Consumer:
□ Cancel Ordei— увеличивает доступный кредит;
□ Create Ordei— уменьшает доступный кредит;
166 Глава 4 • Управление транзакциями с помощью повествований
□ Cancel Order — компенсирующая транзакция, которая уменьшает доступный
кредит.
В этом сценарии повествование Create Order выполняет «грязное» чтение доступ
ного кредита, что позволяет клиенту разместить заказ, превышающий его кредитный
лимит. Вполне вероятно, что такой риск для компании неприемлем.
Посмотрим, как не дать этой и другим аномалиям повлиять на приложение.
4.3.2. Контрмеры на случай нехватки изолированности
Повествования имеют транзакционную модель ACD, нехватка изолированности
в которой может привести к аномалиям, которые выводят приложение из строя.
Разработчик обязан писать свои повествования так, чтобы избежать этих аномалий
или минимизировать их влияние на бизнес. Может показаться, что эта задача не из
легких, но вы уже видели пример стратегии, которая предотвращает аномалии. Один
из возможных подходов — использование в заказе состояний вида *_PENDING, таких
как APPROVAL PENDING. Повествование, обновляющее заказы, например Create Order,
вначале устанавливает состояние в *_PENDING. Благодаря этому другие транзакции
будут знать, что заказ обновляется повествованием, и смогут среагировать соот
ветствующим образом.
Применение в заказах состояний вида *_PENDING — это пример того, что Ларс
Фрэнк (Lars Frank) и Торбен Захл (Torben U. Zahle) называют в своей статье
«Семантические ACID-свойства в среде с несколькими БД и использованием уда
ленного вызова процедур и распространения обновлений» (https://dl.acm.org/cita-
tion.cfm?id=284472.284478) контрмерой семантической блокировки. Эта статья объ
ясняет, как справляться с недостаточной изоляцией в архитектуре с несколькими
базами данных, которая не использует распределенных транзакций. Многие приемы,
которые в ней описаны, могут пригодиться при проектировании повествований.
Для борьбы с аномалиями, вызванными нехваткой изолированности, в ней пред
ложен ряд контрмер, которые либо предотвращают одну или несколько аномалий,
либо минимизируют их последствия для бизнеса. Решения, рассмотренные в этой
статье, перечислены далее.
□ Семантическая блокировка — блокировка на уровне приложения.
□ Коммутативные обновления — проектирование операций обновления таким об
разом, чтобы их можно было выполнить в любом порядке.
□ Пессимистическое представление — перестановка этапов повествования для
минимизации бизнес-рисков.
□ Повторное чтение значения — предотвращение «грязного» чтения путем повтор
ного считывания данных. Это позволяет убедиться в их неизменности перед тем,
как их перезаписывать.
□ Файл версий — ведение записей об обновлениях, чтобы их можно было менять
местами.
□ По значению — использование бизнес-рисков каждого запроса для динамического
выбора механизма конкурентности.
4.3. Что делать с недостаточной изолированностью 167
Все эти контрмеры будут рассмотрены позже в этом разделе, но сначала я хочу
познакомить вас с технологией описания структуры повествований, которая при
годится при обсуждении контрмер.
Структура повествования
В статье о контрмерах, которая упоминается в предыдущем разделе, предложена по
лезная модель для структурирования повествований (рис. 4.8). В ней повествование
состоит из трех типов транзакций.
□ Транзакции, доступные для компенсации, — транзакции, которые потенциально
можно откатить с помощью компенсирующих транзакций.
□ Поворотная транзакция — решающий момент в повествовании. Если поворот
ная транзакция фиксируется, повествование отработает до конца. Поворотная
транзакция может оказаться недоступной ни для компенсации, ни для повторе
ния. Э го также может быть последняя компенсируемая или первая повторяемая
транзакция.
□ Транзакции, доступные для повторения, — транзакции, идущие за поворотной.
Всегда завершаются успешно.
Рис. 4.8. Повествование состоит из транзакций трех типов: компенсируемых (их можно откатить
при наличии компенсирующих транзакций), поворотной (решающий момент повествования)
и повторяемых (их не нужно откатывать, и они всегда завершаются)
В рамках саги Create Order компенсируемыми транзакциями являются этапы
createOrder(), verifyConsumerDetails() и CreateTicket(). У операций createOrder()
и CreateTicket () есть компенсирующие транзакции, которые отменяют их обнов
ления. Операция verifyConsumerDetails() выполняет лишь чтение, поэтому ее
не нужно компенсировать. authorizeCreditCard() — это поворотная транзакция
168 Глава 4 • Управление транзакциями с помощью повествований
в данном повествовании. Если банковскую карту заказчика удается авторизовать,
завершение повествования гарантировано. За поворотной транзакцией следуют
операции approveTicket() и approveOrder(), которые можно повторить.
Особенно важно здесь различие между компенсируемыми и повторяемыми
транзакциями. Как вы увидите сами, каждый тип транзакций играет свою роль
в контрмерах. В главе 13 утверждается, что при переходе на микросервисы монолит
иногда должен принимать участие в повествованиях, и все будет намного проще,
если ему нужно будет выполнять исключительно повторяемые транзакции.
Теперь рассмотрим каждую отдельную контрмеру, начиная с семантической
блокировки.
Контрмера «сематическая блокировка»
При использовании семантической блокировки компенсируемая транзакция уста-
навливает флаг во всех записях, которые она создает или обновляет. Он говорит
о том, что запись не зафиксирована и может измениться. Это может быть либо
блокировка, которая закрывает доступ к записи другим транзакциям, либо пред
упреждение о том, что данную запись следует перепроверять. Флаг сбрасывается
либо повторяемой (повествование успешно завершается), либо компенсирующей
транзакцией (повествование откатывается обратно).
Поле Order.state — отличный пример семантической блокировки. Для ее реа
лизации используются состояния вида *_PENDING, такие как APPROVAL-PENDING
и REVISION-PENDING. Всем, кто обращается к заказу, они говорят о том, что в данный
момент он обновляется другим повествованием. Например, на первом этапе (кото
рый является компенсируемой транзакцией) повествование Create Order создает
заказ с состоянием APPROVAL-PENDING. На заключительном этапе (повторяемая транз
акция) это поле меняется на APPROVED, а компенсирующая транзакция присваивает
ему значение REJECTED.
Но управление блокировкой — это лишь половина проблемы. Вам также нужно
решить, как каждое отдельное повествование будет обращаться с заблокированной
записью. Рассмотрим в качестве примера системную команду cancelorder (). С ее по
мощью клиент может отменить заказ, находящийся в состоянии APPROVAL-PENDING.
Эту задачу можно решить несколькими способами. Системная команда сап-
celOrder() может просто отказать и посоветовать клиенту повторить попытку позже.
Основное преимущество этого подхода — простая реализация. А недостаток в том,
что клиент усложняется за счет логики повторного вызова.
В качестве еще одного варианта команда cancelOrder() может сама заблокиро
ваться до снятия блокировки. Преимущество семантических блокировок состоит
в том, что они, в сущности, воссоздают уровень изолированности, обеспеченный
ACID-транзакциями. Повествования, обновляющие одну и ту же запись, сериа
лизуются, что значительно упрощает написание кода. Еще одной положительной
стороной является то, что клиенту больше не нужно отвечать за повторные вызовы.
Однако при этом приложение должно управлять блокировками. Оно должно также
реализовать алгоритм обнаружения взаимного блокирования, который откатывает
повествование, чтобы снять блокировку, и выполняет его заново.
4.3. Что делать с недостаточной изолированностью 169
Контрмера «коммутативные обновления»
Простой и понятной контрмерой является проектирование коммутативных опера
ций обновления. Операции называют коммутативными, если их можно выполнить
в любом порядке. В качестве примера можно привести команды debit () и credit ()
из сервиса Accounting (если не брать во внимание проверки перерасхода средств).
Это полезная контрмера, так как она устраняет множество обновлений.
Представьте себе сценарий, в котором повествование нужно откатить после того,
как компенсируемая транзакция уже сняла (или возместила) средства со счета.
Компенсирующая транзакция может просто возместить (или снять) нужную сумму,
чтобы отменить обновление. Возможность того, что это перезапишет обновления,
сделанные другими повествованиями, отсутствует.
Контрмера «пессимистическое представление»
Справиться с недостаточной изолированностью, можно также с помощью песси
мистического представления. Оно меняет местами этапы повествования, чтобы
минимизировать бизнес-риски, связанные с «грязным» чтением. Вернемся к при
меру аномалии «грязного» чтения, которую мы обсуждали ранее. В этом сценарии
повествование Create Order выполняет «грязное» чтение доступных кредитных
средств и создает заказ, превышающий кредитный лимит заказчика. Чтобы умень
шить вероятность этой ситуации, данная контрмера переставит этапы повествования
следующим образом.
1. Сервис Order. Меняет состояние заказа на CANCELLED.
2. Сервис Delivery. Отменяет доставку.
3. Сервис Customer. Увеличивает доступный кредит.
В этой упорядоченной версии повествования доступные кредитные средства
увеличиваются в рамках повторяемой транзакции, что исключает возможность
«грязного» чтения.
Контрмера «повторное чтение значения»
Повторное чтение значения предотвращает потерю обновлений. Повествование,
использующее эту контрмеру, повторно считывает запись перед ее обновлением,
убеждается в том, что та не изменилась, и только потом обновляет. Если запись
изменилась, повествование прекращает работу и, возможно, запускается заново.
Это разновидность шаблона «Оптимистичная автономная блокировка» (https://
martinfowler.com/eaaCatalog/optimisticOfflineLock.html).
Повествование Create Order может применять эту контрмеру для сценария, в ко
тором заказ отменяется в процессе подтверждения. Транзакция, подтверждающая
заказ, проверяет, не изменился ли он с момента создания в текущем повествовании.
Не обнаружив изменений, транзакция подтверждает заказ. Но если заказ был от
менен, транзакция прерывает повествование, в результате чего выполняются его
компенсирующие транзакции.
170 Глава 4 • Управление транзакциями с помощью повествований
Контрмера «файл версий»
Файл версий назван так потому, что в него записываются операции, которые выпол
няются с записью. Благодаря этому порядок следования операций можно изменить.
Это способ превращения некоммутативных обновлений в коммутативные. Чтобы по
казать, как работает эта контрмера, рассмотрим сценарий, в котором повествования
Create Order и Cancel Order выполняются параллельно. Если здесь не применяется
семантическая блокировка, существует вероятность того, что повествование Cancel
Order отменит авторизацию банковской карты заказчика до того, как Create Order
ее авторизует.
Чтобы справиться с этими перепутанными запросами, сервис Accounting
может записывать операции по мере поступления и затем выполнять их в пра
вильном порядке. В рассматриваемом случае он сначала запишет запрос Cancel
Authorization. Затем, получив запрос Authorize Card, просто пропустит авторизацию
банковской карты, так как у него уже есть информация о получении запроса Cancel
Authorization.
Контрмера «по значению»
Последняя контрмера называется «по значению». Это стратегия выбора механиз
мов конкурентности на основе бизнес-рисков. Приложение, которое ее применяет,
использует свойства всех запросов, чтобы сделать выбор между повествованиями
и распределенными транзакциями. Таким образом, запросы с низким уровнем риска
выполняются в виде повествований и, возможно, с помощью контрмер, описанных
в предыдущем разделе. По запросы с повышенным риском (например, связанные
с большими суммами денег) задействуют распределенные транзакции. Благодаря
этой стратегии приложение может динамически искать баланс между бизнес-ри-
сками, доступностью и масштабируемостью.
При написании повествований в своем приложении вам, вероятно, придется
использовать одну или несколько из этих контрмер. Давайте подробно рассмотрим
архитектуру и реализацию повествования Create Order с применением семантиче
ской блокировки.
4.4. Архитектура сервиса Order и повествования
Create Order
Итак, мы обсудили различные трудности, присущие архитектуре и реализации
повествований. Теперь рассмотрим пример. Архитектура сервиса Order показана
на рис. 4.9. Его бизнес-логика состоит из традиционных классов, которые описы
вают сам сервис (OrderServlce) и заказы (Order). Есть также классы-оркестраторы,
например CreateOrderSaga для оркестрации повествования Create Order. К тому
же, поскольку сервис Order участвует в собственных повествованиях, у него есть
класс-адаптер OrderCommandHandlers, который обрабатывает командные сообщения,
вызывая Orderservice.
4.4. Архитектура сервиса Order и повествования Create Order 171
Рис. 4.9. Архитектура сервиса Order и его повествований
Некоторые участки сервиса Order должны быть вам знакомы. Как и в традици
онных приложениях, ядро бизнес-логики реализуется классами Orderservice, Order
и OrderRepository. В этой главе мы лишь кратко по ним пройдемся, а подробное
описание будет представлено в главе 5.
Менее знакомыми могут показаться классы, связанные с повествованиями.
Этот сервис — одновременно и оркестратор, и участник повествований. У него
есть несколько классов-оркестраторов, таких как CreateOrderSaga. Они от
правляют командные сообщения участникам повествования, используя такие
прокси-классы, как KitchenServiceProxy и OrderServiceProxy. Прокси-классы
172 Глава 4 • Управление транзакциями с помощью повествований
описывают API обмена сообщениями участника. Сервис Order также содержит класс
OrderCommandHandlers, который обрабатывает командные сообщения, отправленные
его повествованиями.
Рассмотрим эту архитектуру подробнее. Начнем с класса Orderservice.
4.4.1. Класс OrderService
Класс OrderService — это доменный сервис, который вызывается через его API.
Он отвечает за создание и обновление заказов. На рис. 4.10, помимо OrderService,
показано несколько сопутствующих классов. OrderService создает и обновляет
заказы, сохраняет их с помощью OrderRepository и использует SagaManager для
создания повествований, таких как CreateOrderSaga. SagaManager — эго один из
классов, входящих в состав фреймворка Eventuate Tram Saga, предназначенного
для написания оркестраторов и участников повествований. Поговорим о нем чуть
позже в этой главе.
Рис. 4.10. OrderService создает и обновляет заказы, сохраняет их с помощью OrderRepository
и создает повествования, включая CreateOrderSaga
Более подробно этот класс обсуждается в главе 5. А пока сосредоточимся на ме
тоде createOrder () из класса OrderService. Далее вы видите его листинг 4.1. Сначала
этот метод создает заказ, а затем проверяет его с помощью CreateOrderSaga.
Метод createOrder() создает заказ, вызывая фабричный метод Order.crea
teOrder (). Затем сохраняет заказ с помощью класса OrderRepository, который пред
ставляет собой репозиторий на основе JPA. Он вызывает метод SagaManager. create(),
чтобы создать CreateOrderSaga, и передает объект CreateOrderSagaState с иден
тификатором только что сохраненного заказа и информацией о нем (в виде
OrderDetails). Создав экземпляр оркестратора повествований, класс SagaManager
отправляет командное сообщение первому участнику и сохраняет оркестратор
в базе данных.
4.4. Архитектура сервиса Order и повествования Create Order 173
Листинг 4.1. Класс OrderService и его метод createOrder()
^Transactional 4—
public class OrderService { Делаем методы сервиса
транзакционными
gAutowired
private SagaManager<CreateOrderSagaState> createOrderSagaManager;
@Autowired
private OrderRepository orderRepository;
gAutowired
private DomainEventPublisher eventpublisher;
public Order createOrder(OrderDetails orderDetails) { Создаем заказ
ResultWithEvents<Order> orderAndEvents = Order.createOrder(...);
Order order = orderAndEvents.result;
orderRepository.save(order); 4-------
Сохраняем заказ
в базе данных
eventPublisher.publish(Order.class, 4------
Long.toString(order.getld()),
orderAndEvents.events);
Публикуем
доменные события
CreateOrderSagaState data =
new CreateOrderSagaState(order.getId(), orderDetails); ◄-----
createOrderSagaManager.create(data, Order.class, order.getld());
return order;
}
Создаем CreateOrderSaga
} "
Рассмотрим повествование CreateOrderSaga и связанные с ним классы.
4.4.2. Реализация повествования Create Order
На рис. 4.11 показаны классы, реализующие повествование Create Order. Они имеют
следующие обязанности.
□ CreateOrderSaga — класс-синглтон, который описывает конечный автомат по
вествования. Он создает командные сообщения, вызывая CreateOrderSagaState,
и рассылает их участникам с помощью каналов, указанных такими прокси-клас
сами, как KitchenServiceProxy.
□ CreateOrderSagaState — сохраненное состояние повествования, генерирующее
командные сообщения.
□ Прокси-классы участников, такие как KitchenServiceProxy, — каждый прокси
класс определяет API обмена сообщениями для участника повествования, кото
рый состоит из командного канала, типов командных сообщений и типов ответов.
Эти классы написаны с помощью фреймворка Eventuate Tram Saga.
174 Глава 4 • Управление транзакциями с помощью повествований
Рис. 4.11. Повествования сервиса Orderservice, такие как Create Order, реализуются с помощью
фреймворка Eventuate Tram Saga
Фреймворк Eventuate Tram Saga предоставляет предметно-ориентированный
язык (domain-specific language, DSL) для описания конечного автомата повествова
ния. Eventuate Tram также помогает с запуском этого конечного автомата, обменом
сообщениями с участниками повествования и сохранением состояния повествова
ния в базе данных.
Давайте подробнее обсудим реализацию повествования Create Order, начиная
с класса CreateOrderSaga.
4.4. Архитектура сервиса Order и повествования Create Order 175
Оркестратор CreateOrderSaga
Класс CreateOrderSaga определяет конечный автомат, показанный ранее на рис. 4.7.
Он реализует базовый интерфейс повествований, SimpleSaga. Его основной частью
является описание повествования, представленное в листинге 4.2. Для определения
этапов Create Order этот класс использует фреймворк Eventuate Tram Saga.
Листинг 4.2. Определение класса CreateOrderSaga
public class CreateOrderSaga implements SimpleSaga<CreateOrderSagaState> {
private SagaDefinition<CreateOrderSagaState> sagaDefinition;
public CreateOrderSaga(OrderServiceProxy orderservice,
ConsumerServiceProxy consumerservice,
KitchenServiceProxy kitchenService,
AccountingServiceProxy accountingservice) {
this.sagaDefinition =
step()
.withCompensation(orderservice.reject,
CreateOrderSagaState:imakeRejectOrderCommand)
.step()
.invokeParticipant(consumerservice.validateOrder,
CreateOrderSagaState::makeValidateOrderByConsumerCommand)
,step()
.invokeParticipant(kitchenService.create,
CreateOrderSagaState::makeCreateTicketCommand)
,onReply(CreateTicketReply.class,
CreateOrderSagaState::handleCreateTicketReply)
.withCompensation(kitchenService.cancel,
CreateOrderSagaState::makeCancelCreateTicketCommand)
,step()
.invokeParticipant(accountingservice.authorize,
CreateOrderSagaState::makeAuthorizeCommand)
,step()
.invokeParticipant(kitchenService.confirmCreate,
CreateOrderSagaState::makeConfirmCreateTicketCommand)
•step()
.invokeParticipant(orderservice.approve,
CreateOrderSagaState::makeApproveOrderCommand)
.build();
}
^Override
public SagaDefinition<CreateOrderSagaState> getSagaDefinition() {
return sagaDefinition;
}
Конструктор CreateOrderSaga создает определение повествования и сохраняет
его в поле sagaDefinition. Для возвращения этого определения используется метод
getSagaDefinition().
176 Глава 4 • Управление транзакциями с помощью повествований
Чтобы понять, как работает CreateOrderSaga, рассмотрим определение третьего
этапа повествования, который показан в листинге 4.3. На этом этапе для создания
заявки вызывается сервис Kitchen. Отменяется заявка с помощью компенсирующей
транзакции. Методы step(), invokeParticipant(), onReplyQ и withCompensation() —
это часть языка DSL, предоставленного фреймворком Eventuate Tram Saga.
Листинг 4.3. Определение третьего этапа повествования
public class CreateOrderSaga ...
Вызываем handleCreateTicketRepIyO
при получении успешного ответа
public CreateOrderSaga(..
...) {
KitchenServiceProxy kitchenService,
.step()
.invokeParticipant(kitchenService.create, <■
Определяем
прямую транзакцию
CreateOrderSagaState::makeCreateTicketCommand)
.onReply(CreateTicketReply.class,
CreateOrderSagaState::handleCreateTicketReply) ◄-----
.withCompensation(kitchenService.cancel, ◄-----
CreateOrderSagaState::makeCancelCreateTicketCommand)
Определяем
компенсирующую транзакцию
Вызов invokeParticipant() определяет прямую транзакцию. Он создает ко
мандное сообщение CreateTicket, вызывая метод CreateOrderSagaState.makeCre-
ateTicketCommand(), и отправляет его в канал, заданный операцией kitchenservice,
create. В вызове onReply() указано, что метод CreateOrderSagaState. hand
leCreateTicketRepIyO должен вызываться при получении успешного ответа от
сервиса Kitchen. Этот метод сохраняет возвращенный идентификатор ticketld
в состоянии CreateOrderSagaState. Вызов withCompensation() определяет компен
сирующую транзакцию. Он создает командное сообщение RejectTicketCommand,
вызывая CreateOrderSagaState.makeCancelCreateTicketQ, и отправляет его в канал,
заданный операцией kitchenService. create.
Другие этапы повествования описываются примерно так же. CreateOrderSa
gaState создает каждое сообщение, которое повествованием отправляется в конеч
ную точку, заданную классом KitchenServiceProxy. Рассмотрим каждый из этих
классов, начиная с CreateOrderSagaState.
Класс CreateOrderSagaState
Класс CreateOrderSagaState, показанный в листинге 4.4, представляет состояние
экземпляра повествования. Экземпляр этого класса создается сервисом Orderservice
и сохраняется в базе данных фреймворком Eventuate Tram Saga. Его основная обя
занность заключается в создании сообщений, которые рассылаются участникам
повествования.
4.4. Архитектура сервиса Order и повествования Create Order 177
Листинг 4.4. CreateOrderSagaState хранит состояние экземпляра повествования
public class CreateOrderSagaState {
private Long orderld;
private OrderDetails orderDetails;
private long ticketld;
public Long getOrderId() {
return orderld;
}
private CreateOrderSagaState() {
}
Вызывается сервисом OrderService
для создания экземпляра
CreateOrderSagaState
public CreateOrderSagaState(Long orderld, OrderDetails orderDetails) {
this.orderld = orderld;
this.orderDetails = orderDetails;
} Создает командное
сообщение CreateTicket
CreateTicket makeCreateTicketCommand() {
return new CreateTicket(getOrderDetails().getRestaurantId(),
getOrder!d(), makeTicketDetails(getOrderDetails()));
}
void handleCreateTicketReply(CreateTicketReply reply) {
logger.debug("getTicketId {}", reply.getTicketId());
setTicketld(reply.getTicketld());
}
CancelCreateTicket makeCancelCreateTicketCommand() {
return new CancelCreateTicket(getOrderId());
}
Сохраняет ID только что
созданной заявки
Создает командное
сообщение CancelCreateTicket
CreateOrderSaga вызывает CreateOrderSagaState для создания командных со
общений, которые затем отправляются в конечную точку, заданную классами
SagaParticipantProxy. Обсудим один из этих классов — KitchenServiceProxy.
Класс KitchenServiceProxy
Класс KitchenServiceProxy, показанный в листинге 4.5, описывает конечные точки
командных сообщений для сервиса Kitchen. Всего таких точек три:
□ create — создает заявку;
□ confirmCreate — подтверждает создание;
□ cancel — отменяет заявку.
Каждая точка Comma nd End point указывает тип команды, канал командного со
общения и типы ожидаемых ответов.
178 Глава 4 • Управление транзакциями с помощью повествований
Листинг 4.5. KitchenServiceProxy описывает конечные точки командных сообщений для сервиса Kitchen
public class KitchenServiceProxy {
public final CommandEndpoint<CreateTicket> create =
CommandEndpointBuilder
.forCommand(CreateTicket.class)
.withChannel(
KitchenServiceChannels.kitchenServiceChannel)
.withReply(CreateTicketReply.class)
.build();
public final CommandEndpoint<ConfirmCreateTicket> confirmCreate =
CommandEndpointBuilder
.forCommand(ConfirmCreateT icket.class)
.withChannel(
KitchenServiceChannels.kitchenServiceChannel)
.withReply(Success.class)
.build();
public final CommandEndpoint<CancelCreateTicket> cancel =
CommandEndpointBuilder
.forCommand(CancelCreateT icket.class)
.withChannel(
KitchenServiceChannels.kitchenServiceChannel)
.withReply(Success.class)
.build();
}
Строго говоря, прокси-классы, такие как KitchenServiceProxy, — необязатель
ные. Повествование может просто отправлять командные сообщения непосред
ственно участникам. Однако прокси-классы обладают важными преимуществами.
Во-первых, прокси-класс описывает статически типизированные конечные точки —
это снижает вероятность того, что повествование отправит сервису некорректное
сообщение. Во-вторых, прокси-класс представляет собой строго очерченный API
для вызова сервиса, упрощающий понимание и тестирование кода. Например,
в главе 10 показано, как написать тесты для KitchenServiceProxy, которые про
веряют корректность обращения к сервису Kitchen со стороны сервиса Order.
Без KitchenServiceProxy написание такого узкоспециализированного теста было бы
невозможным.
Фреймворк Eventuate Tram Saga
Фреймворк Eventuate Tram Saga, представленный на рис. 4.12, предназначен для
написания как оркестраторов, так и участников повествований. Он использует воз
можности транзакционного обмена сообщениями, встроенные в Eventuate Tram
(см. главу 3).
Самая сложная часть данного фреймворка — пакет saga orchestration. Он предо
ставляет SimpleSaga — базовый интерфейс для повествований и SagaManager -- класс
4.4. Архитектура сервиса Order и повествования Create Order 179
Рис. 4.12. Фреймворк Eventuate Tram Saga предназначен для написания как оркестраторов,
так и участников повествований
для создания экземпляров повествования и управления ими. SagaManager отвечает
также за сохранение повествования, отправку командных сообщений, которые оно
генерирует, подписку на ответные сообщения и вызов повествования для обработ
ки ответов. На рис. 4.13 показана последовательность событий, когда OrderService
создает повествование. Она состоит из следующих шагов.
1. Сервис OrderService создает CreateOrderSagaState.
2. Он создает экземпляр повествования путем вызова SagaManager.
3. SagaManager выполняет первый этап из определения повествования.
4. Вызывается CreateOrderSagaState для генерации командного сообщения.
5. SagaManager шлет командное сообщение участнику повествования (сервису
Consumer).
6. SagaManager сохраняет экземпляр повествования в базе данных.
180 Глава 4 • Управление транзакциями с помощью повествований
Рис. 4.13. Последовательность событий, когда OrderService создает повествование Create Order
На рис. 4.14 показана последовательность событий, когда SagaManager получает
ответ от сервиса Consumer.
Рис. 4.14. Последовательность событий, когда SagaManager получает ответ от участника
повествования
Последовательность событий выглядит так.
1. Eventuate Tram вызывает SagaManager с ответом, полученным от сервиса Consumer.
2. SagaManager извлекает экземпляр повествования из базы данных.
3. SagaManager выполняет следующий этап из определения повествования.
4. Вызывается CreateOrderSagaState, чтобы сгенерировать командное сообщение.
5. SagaManager шлет командное сообщение заданному участнику повествования
(сервису Kitchen).
6. SagaManager сохраняет обновленный экземпляр повествования в базе данных.
4.4. Архитектура сервиса Order и повествования Create Order 181
Если участнику повествования не удастся выполнить команду, SagaManager вы
зовет компенсирующие транзакции в обратном порядке.
Еще одной частью фреймворка Eventuate Tram Saga является пакет saga partici
pant. Он предоставляет классы SagaCommandHandlersBuilder и SagaCommandDispatcher
для написания участников повествования. Эти классы направляют командные
сообщения к методам-обработчикам, которые вызывают бизнес-логику участника
и генерируют ответы. Посмотрим, как эти классы применяются в сервисе Order.
4.4.3. Класс OrderCommandHandlers
Сервис Order участвует в своих собственных повествованиях. Например, CreateOr
derSaga обращается к нему, чтобы он подтвердил или отклонил заказ. Класс
OrderCommandHandlers (рис. 4.15) определяет методы-обработчики для командных
сообщений, отправляемых этими повествованиями.
Рис. 4.15. OrderCommandHandlers реализует обработчики для команд, отправляемых различными
повествованиями сервиса Order
Каждый обработчик вызывает OrderService, чтобы обновить заказ, и генерирует
ответное сообщение. Класс SagaCommandDispatcher направляет командные сообще
ния подходящим методам и возвращает ответ.
В листинге 4.6 показан класс OrderCommandHandlers. Его метод commandHandlers()
привязывает типы командных сообщений к подходящим обработчикам. Каждый
обработчик принимает командное сообщение в качестве параметра, вызывает
OrderService и возвращает ответ.
182 Глава 4 • Управление транзакциями с помощью повествований
Листинг 4-6. Обработчики команд для сервиса Order
public class OrderCommandHandlers {
gAutowired
private Orderservice orderservice; Направляет каждое командно* сообщение
подходящему методу-обработчику
public CommandHandlers commandHandlers() { ◄—
return SagaCommandHandlersBuilder
.fromChannel("OrderService")
.onMessage(ApproveOrderCommand.class, this::approveOrder)
.onMessage(RejectOrderCommand.class, this::rejectorder)
.build();
}
public Message approveOrder(CommandMessage<ApproveOrderCommand> cm) {
long orderld = cm.getCommand(),getOrderId();
orderservice.approveOrder(orderld); <
return withSuccess();
Возвращает стандартное сообщение
об успешном выполнении
Меняет состояние
заказа на AUTHORIZED
}
public Message rejectOrder(CommandMessage<RejectOrderCommand> cm) {
long orderld = cm.getCommand().getOrderId();
orderService.rejectOrder(orderld);
return withSuccess»; Меняет состояние заказа на REJECTED
Методы approveOrder() и rejectOrder() обновляют заданный заказ, вызывая
OrderService. Другие сервисы, участвующие в повествовании, имеют похожие клас
сы для обработки команд, которые обновляют их доменные объекты.
4.4.4. Класс OrderServiceConfiguration
Сервис Order использует фреймворк Spring. В листинге 4.7 показан фрагмент кон
фигурационного класса OrderServiceConfiguration, который создает экземпляры
@Веап из состава Spring и соединяет их между собой.
Листинг 4.7. OrderServiceConfiguration — это конфигурационный класс из состава Spring, который
определяет экземпляры @Веап для сервиса Order
^Configuration
public class OrderServiceConfiguration {
@Bean
public OrderService orderService(RestaurantRepository restaurantRepository,
SagaManager<CreateOrderSagaState>
createOrderSagaManager,
...) {
return new OrderService(restaurantRepository,
4.4. Архитектура сервиса Order и повествования Create Order 183
createOrderSagaManager
}
@Bean
public SagaManager<CreateOrderSagaState> createOrderSagaManagerCCre-
ateOrderSaga saga) {
return new SagaManagerlmplo(saga);
}
@Bean
public CreateOrderSaga createOrderSaga(OrderServiceProxy orderservice,
ConsumerServiceProxy consumerservice,
...) {
return new CreateOrderSaga(orderService, consumerservice, ...);
}
@Bean
public OrderCommandHandlers orderCommandHandlers() {
return new OrderCommandHandlers();
}
@Bean
public SagaCommandDispatcher orderCommandHandlersDispatcher(OrderCom-
mandHandlers OrderCommandHandlers) {
return new SagaCommandDispatcher(’’orderservice", orderCommandHandlers.com-
mandHandlers());
}
@Bean
public KitchenServiceProxy kitchenServiceProxy() {
return new KitchenServiceProxy();
}
@Bean
public OrderServiceProxy orderServiceProxy() {
return new OrderServiceProxy();
}
}
Этот класс определяет несколько экземпляров @Веап, включая orderservice,
createOrderSagaManager, CreateOrderSaga, OrderCommandHandlers и orderCom-
mandHandlersDispatcher. Он также определяет экземпляры (ЭВеап для различных
прокси-классов, в том числе kitchenServiceProxy и OrderServiceProxy.
CreateOrderSaga — это лишь одно из множества повествований сервиса Order.
Многие другие его системные операции тоже используют повествования. Например,
операция cancelorder() задействует повествование Cancel Order, a reviseorder() —
Revise Order. В итоге, несмотря на наличие у многих сервисов внешних API
184 Глава 4 • Управление транзакциями с помощью повествований
с синхронными протоколами, такими как REST или gRPC, большая часть интен
сивного взаимодействия будет основана на асинхронных сообщениях.
Как видите, управление транзакциями и некоторые аспекты проектирования
бизнес-логики в микросервисной архитектуре довольно уникальны. К счастью,
оркестраторы повествований обычно представляют собой простые конечные авто
маты, а для упрощения написания повествований можно использовать специаль
ные фреймворки. Конечно, по сравнению с монолитной архитектурой управление
транзакциями, несомненно, выглядит сложнее. Но это невысокая цена за огромные
преимущества микросервисов.
Резюме
□ Некоторым системным операциям нужно обновлять данные, разбросанные
по разным сервисам. Распределенные транзакции, основанные на ХА/2РС, —
не самый подходящий выбор для современных приложений. Вместо них лучше
использовать шаблон «Повествование». Повествование — это последовательность
локальных транзакций, которые координируются с помощью сообщений. Каждая
локальная транзакция обновляет данные лишь в одном сервисе. При этом все
изменения фиксируются, поэтому, если повествование нужно откатить из-за на
рушения бизнес-правила, оно должно выполнить компенсирующие транзакции,
чтобы явно отменить внесенные изменения.
□ Для координации этапов повествования можно применять либо хореографию,
либо оркестрацию. В повествованиях, основанных на хореографии, локальная
транзакция публикует события, которые заставляют других участников выпол
нить свои локальные транзакции. При использовании оркестрации централизо
ванный оркестратор рассылает участникам сообщения с инструкциями, какие
локальные транзакции нужно выполнить. Вы можете упростить разработку и те
стирование, смоделировав оркестратор в виде конечного автомата. Хореография
подходит для простых повествований, но в сложных случаях лучше применять
оркестрацию.
□ Проектирование бизнес-логики, основанной на повествованиях, может оказаться
проблематичным, поскольку повествования, в отличие от ACID-транзакций,
не изолированы друг от друга. В связи с этим часто приходится задействовать
контрмеры — стратегии проектирования, которые предотвращают аномалии
конкурентного выполнения, присущие транзакционной модели ACD. Иногда
для упрощения бизнес-логики необходимо использовать блокировки, которые
сами по себе чреваты взаимным блокированием.
Проектирование
бизнес-логики
в микросервисной
архитектуре
Сердцем промышленных приложений является бизнес-логика, которая реализует
бизнес-правила. Разработка сложной бизнес-логики всегда сопряжена с определен
ными трудностями. Приложение FTGO реализует довольно замысловатую бизнес-
логику, особенно для управления заказами и доставкой. Мэри поощряла свою команду
применять принципы объектно-ориентированного проектирования, поскольку, исходя
из ее опыта, это лучший способ реализации сложной бизнес-логики. На некоторых
участках приложения использовался процедурный шаблон «Сценарий транзакции».
Но большая часть кода была реализована в соответствии с объектно-ориентирован
ной доменной моделью, которая накладывалась на базу данных с помощью JPА.
В микросервисной архитектуре разрабатывать сложную бизнес-логику оказы
вается еще труднее, потому что она распределена между разными микросервисами.
Вам необходимо решить две ключевые проблемы. Типичная доменная модель вы
глядит как паутина из связанных между собой классов. В монолитных приложениях
в этом нет ничего плохого, но в микросервисной архитектуре, где классы разбросаны
по разным сервисам, нужно избавиться от ссылок на объекты, которые пересекают
186 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
границы сервисов. Еще одна проблема заключается в проектировании бизнес-логи
ки, которая работает в рамках ограничений, накладываемых работой с транзакциями
в микросервисной архитектуре. Вы можете применять ACID-транзакции внутри од
ного сервиса, но, как говорилось в главе 4, для обеспечения согласованности данных
между сервисами следует использовать шаблон «Повествование».
К счастью, для преодоления этих трудностей можно воспользоваться шаблоном
«Агрегат» из состава DDD. Он структурирует бизнес-логику приложения в виде
набора агрегатов. Агрегат — это кластер объектов, с которыми можно обращаться
как с единым целым. Есть две причины, почему агрегаты могут пригодиться при
разработке бизнес-логики в микросервисной архитектуре.
□ Агрегаты исключают любую возможность того, что ссылки на объекты могут
выйти за рамки одного сервиса, потому что межагрегатная ссылка — это скорее
значение первичного ключа, а не объектная ссылка.
□ Транзакция может создать или обновить лишь один агрегат, поэтому агрегаты
соответствуют ограничениям транзакционной модели микросервисов.
Благодаря этому ACID-транзакция никогда не выйдет за пределы одного сервиса.
Я начну эту главу с описания разных способов организации бизнес-логики —
шаблонов «Сценарий транзакции» и «Доменная модель». Затем вы познакомитесь
с концепцией агрегатов из DDD и узнаете, почему они являются хорошими стро
ительными блоками для бизнес-логики сервисов. После этого я опишу шаблон
«Доменная модель» и объясню, почему сервису следует публиковать свои события.
В конце главы будут представлены несколько примеров бизнес-логики из сервисов
Kitchen и Order.
Рассмотрим шаблоны организации бизнес-логики.
5.1. Шаблоны организации бизнес-логики
На рис. 5.1 показана архитектура типичного сервиса. Как говорилось в главе 2,
бизнес-логика является ядром шестигранной архитектуры. Ее окружают входящие
и исходящие адаптеры. Входящий адаптер обрабатывает запросы от клиентов и вы
зывает бизнес-логику. Исходящий адаптер, который сам вызывается бизнес-логикой,
обращается к другим сервисам и приложениям.
Этот сервис состоит из бизнес-логики и следующих адаптеров:
□ адаптера REST API — входящего адаптера, который реализует REST API для
вызова бизнес-логики;
□ OrderCommandHandlers — входящего адаптера, который потребляет из канала
командные сообщения и вызывает бизнес-логику;
□ адаптера базы данных — исходящего адаптера, который вызывается бизнес-ло
гикой для доступа к базе данных;
□ адаптера публикации доменных событий — исходящего адаптера, который пу
бликует события для брокера сообщений.
5.1. Шаблоны организации бизнес-логики 187
Рис. 5.1. Сервис Order имеет шестигранную архитектуру. Он состоит из бизнес-логики и одного
или нескольких адаптеров для доступа к внешним приложениям или другим сервисам
Бизнес-логика обычно оказывается самой сложной частью сервиса. Вы должны
осознанно организовать ее таким образом, который лучше всего подходит для ваше
го приложения. Я уверен, что вам уже знакомо разочарование от поддержки плохо
структурированного кода, написанного кем-то другим. Большинство приложений
уровня предприятия написаны на объектно-ориентированных языках, таких как Java,
поэтому они состоят из классов и методов. Но использование объектно-ориентиро
ванного языка вовсе не означает, что бизнес-логика имеет объектно-ориентирован
ную структуру. Ключевое решение, которое вам придется принять в ходе разработки,
заключается в том, какой подход лучше применять — объектно-ориентированный
или процедурный. Существует два основных шаблона для организации бизнес-ло
гики: процедурный «Сценарий транзакции» и объектно-ориентированный, который
называется «Доменная модель».
188 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
5.1.1. Проектирование бизнес-логики с помощью
шаблона «Сценарий транзакции»
Я большой сторонник объектно-ориентированного подхода, но в некоторых си
туациях он излишен — например, при разработке простой бизнес-логики. В таких
случаях лучше писать процедурный код, используя шаблон, который Мартин
Фаулер в своей книге Patterns of Enterprise Application Architecture (Addison-Wesley
Professional, 2002/ называет сценарием транзакции. Вместо объектно-ориентиро
ванного проектирования вы создаете метод под названием «Сценарий транзакции»,
который обрабатывает запросы из уровня представления. Важная характеристика
этого подхода — то, что классы, реализующие поведение, отделены от классов, ко
торые хранят состояние (рис. 5.2).
Рис. 5.2. Организация бизнес-логики в виде сценариев транзакции. В такой архитектуре
один набор классов обычно реализует поведение, а другой хранит состояние. Сценарии
транзакции организованы в виде классов, у которых нет состояния. Они применяют классы
данных, которые, как правило, не обладают поведением
При использовании этого шаблона сценарии обычно размещаются в классе
сервиса (в данном случае OrderService). Класс сервиса имеет по одному методу
для каждого запроса или системной операции. Метод реализует бизнес-логику для
определенного запроса. Он обращается к БД с помощью объектов доступа к данным
(data access object, DAO), таких как OrderDao. Здесь примером такого объекта явля
ется класс Order, он предназначен исключительно для работы с данными, и у него
почти (или совсем) нет никакого поведения.
1 Фаулер М. Шаблоны корпоративных приложений. — М.: Вильямс, 2016.
5.1. Шаблоны организации бизнес-логики 189
Этот стиль проектирования в основном является процедурным, но при этом ис
пользует несколько возможностей объектно-ориентированных языков программи
рования. Вы бы с помощью этого подхода писали программы на С и других языках
без поддержки объектно-ориентированного программирования (ООП). Тем не менее
в процедурном проектировании, если оно применяется в подходящей ситуации, нет
ничего постыдного. Оно хорошо подходит для простой бизнес-логики, но сложную
логику с его помощью лучше не реализовывать.
5.1.2. Проектирование бизнес-логики с помощью
шаблона «Доменная модель»
Простота процедурного подхода может показаться довольно соблазнительной.
Вы можете писать код без тщательного продумывания организации классов. Но про
блема в том, что при довольно значительном усложнении бизнес-логики поддержка
вашего кода превратится в сплошной кошмар. Как и монолитные приложения, сце
нарии транзакций склонны постоянно разрастаться. Поэтому, если ваше приложение
не является предельно простым, следует воздерживаться от написания процедурного
кода. Вместо этого стоит применять доменную модель и вести разработку в объектно-
ориентированном стиле.
В объектно-ориентированном проектировании бизнес-логика состоит из объект
ной модели — сети относительно небольших классов. Эти классы обычно напрямую
соотносятся с концепциями из проблемной области. В такой архитектуре некоторые
классы обладают либо состоянием, либо поведением, но многие имеют и то и другое,
что является признаком хорошо спроектированного класса. Пример шаблона «До
менная модель» показан на рис. 5.3.
Как и в случае с шаблоном «Сценарий транзакции», у класса OrderService
предусмотрено по одному методу для каждого запроса или системной операции.
Но при использовании доменной модели методы сервисов обычно получаются более
простыми. Это связано с тем, что существенная часть бизнес-логики делегируется
доменным объектам. Метод сервиса может, например, извлечь доменный объект
из базы данных и вызвать один из его собственных методов. В этом примере класс
190 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
Order обладает как состоянием, так и поведением. Более того, его состояние является
приватным, и доступ к нему осуществляется лишь через его методы.
Рис. 5.3. Организация бизнес-логики в виде доменной модели. Большинство классов имеют
и состояние, и поведение
Применение объектно-ориентированного проектирования имеет ряд преиму
ществ. Во-первых, это упрощает понимание и поддержку архитектуры. Вместо одно
го большого класса, который берет на себя все функции, сервис состоит из несколь
ких мелких классов, у каждого из которых есть свой небольшой набор обязанностей.
Кроме того, такие классы, как Account, BankingTransaction и Overdraftpolicy, до
вольно точно отражают реальный мир, благодаря чему их роль в архитектуре проще
понять. Во-вторых, нашу объектно-ориентированную архитектуру легче тестировать:
каждый класс может и должен тестироваться отдельно. И наконец, объектно-ори
ентированный код проще расширять, поскольку в нем можно использовать хорошо
известные шаблоны проектирования, такие как «Стратегия» и «Шаблонный метод»,
которые позволяют расширять компонент без изменения его кода.
Шаблон «Доменная модель» хорошо себя зарекомендовал, но у него есть целый
ряд проблем, особенно в контексте микросервисной архитектуры. Чтобы разобраться
с ними, нужно использовать более узкую версию ООП, известную как DDD.
5.1.3. О предметно-ориентированном проектировании
Предметно-ориентированное проектирование (DDD), описанное в книге Эрика
Эванса Domain-Driven Design, — это более узкая разновидность ООП, предназна
ченная для разработки сложной бизнес-логики. Мы познакомились с DDD в главе 2
при обсуждении поддоменов и того, насколько они подходят для разбиения при-
5.2. Проектирование доменной модели с помощью шаблона «Агрегат» из DDD 191
ложений на сервисы. В DDD каждый сервис имеет собственную доменную модель,
что позволяет избежать проблем с единой доменной моделью, которая охватывает
все приложение. Поддомены и связанная с ними концепция изолированного кон
текста — это два стратегических шаблона DDD.
В DDD есть также тактические шаблоны, которые служат строительными блока
ми для доменных моделей. Каждый шаблон представляет собой роль, которую класс
играет в доменной модели, и описывает характеристики этого класса. Разработчики
широко применяют следующие строительные блоки.
□ Сущность — объект, обладающий устойчивой идентичностью. Две сущности, чьи
атрибуты имеют одинаковые значения, — это все равно разные объекты. В при
ложении Java ЕЕ классы, которые сохраняются с помощью аннотации ^Entity
из JPA, обычно представляют собой сущности DDD.
□ Объект значений — объект, представляющий собой набор значений. Два объекта
значений с одинаковыми атрибутами взаимозаменяемы. Примером таких объ
ектов может служить класс Money, который состоит из валюты и суммы.
□ Фабрика — объект или метод, реализующий логику создания объектов, которую
ввиду ее сложности не следует размещать прямо в конструкторе. Фабрика также
может скрывать конкретные классы, экземпляры которых создает. Она реализу
ется в виде статического метода или класса.
□ Репозиторий — объект, предоставляющий доступ к постоянным сущностям и ин
капсулирующий механизм доступа к базе данных.
□ Сервис — объект, реализующий бизнес-логику, которой не место внутри сущ
ности или объекта значений.
Многие разработчики используют эти строительные блоки. Некоторые из них
поддерживаются такими фреймворками, как JPA и Spring. Но есть еще одна кон
цепция, которую обычно игнорируют все (и я тоже!), за исключением истинных
ценителей DDD. Речь идет об агрегатах. Несмотря на свою непопулярность, этот
строительный блок чрезвычайно полезен при разработке микросервисов. Давайте
рассмотрим некоторые неочевидные проблемы классического ООП, которые можно
решить с помощью агрегатов.
5.2. Проектирование доменной модели
с помощью шаблона «Агрегат» из DDD
В традиционном объектно-ориентированном проектировании доменная модель
описывает набор классов и отношения между ними. Классы обычно сгруппированы
в пакеты. Например, на рис. 5.4 показана часть доменной модели приложения FTGO.
Это типичная доменная модель, представляющая собой паутину взаимосвязанных
классов.
Этот пример содержит несколько классов, которые соотносятся с бизнес-объ-
ектами: Consumer, Order, Restaurant и Courier. Но что интересно, в традиционной
доменной модели не хватает четких границ между разными бизнес-объектами.
192 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
Например, она не уточняет, какие классы входят в состав бизнес-объекта Order.
Иногда такое расплывчатое разделение может вызвать проблемы, особенно в микро
сервисной архитектуре.
Рис. 5.4. Традиционная доменная модель представляет собой паутину
взаимосвязанных классов. Она не определяет четкие границы между бизнес-объектами,
такими как Consumer и Order
Я начну этот раздел с демонстрации проблем, вызванных нечеткими границами.
Затем опишу концепцию агрегата и то, как она определяет четкие границы. После
этого перечислю правила, которым должны подчиняться агрегаты, и покажу, как
они делают эту концепцию хорошим выбором для микросервисной архитектуры.
Вы также научитесь тщательно очерчивать границы для своих агрегатов и узнаете,
почему это важно. В конце мы поговорим о том, как проектировать бизнес-логику
с помощью агрегатов. Но сначала рассмотрим проблемы, вызванные расплывчатыми
границами.
5.2.1. Проблемы с расплывчатыми границами
Представьте, к примеру, что вы хотите выполнить с бизнес-объектом Order опера
цию, такую как загрузка или удаление. Что именно это означает? Какова область
применения этой операции? Загрузить или удалить объект Order — не проблема.
Но в реальности заказ не ограничивается одним лишь этим объектом. Вам придется
иметь дело с позициями заказа, информацией о платеже и т. д. На рис. 5.4 границы
доменного объекта оставлены на усмотрение разработчика.
Помимо концептуальной неопределенности, отсутствие четких границ вызывает
проблемы при обновлении бизнес-объекта. Типичный бизнес-объект имеет инвари
анты — бизнес-правила, которые всегда должны соблюдаться. Например, для заказа
есть минимальная сумма. Приложение FTGO должно следить за тем, чтобы попытка
5.2. Проектирование доменной модели с помощью шаблона «Агрегат» из DDD 193
обновления заказа не нарушила инвариант. Но для соблюдения инвариантов вы
должны тщательно спроектировать бизнес-логику.
Давайте посмотрим, как соблюсти минимальную сумму заказа в ситуации, когда
он создается совместно несколькими клиентами. Два клиента, Сэм и Мэри, вместе
составляют заказ и одновременно приходят к выводу, что он им не по карману.
Сэм уменьшает количество пирожков, а Мэри отказывается от нескольких пшенич
ных лепешек. С точки зрения приложения оба клиента запрашивают заказ и его по
зиции из базы данных. Затем оба обновляют определенную позицию, чтобы снизить
цену. С точки зрения каждого отдельного клиента сумма заказа не опустилась ниже
допустимой отметки. Вот как выглядит последовательность транзакций.
Consumer - Магу
BEGIN TXN
SELECT ORDER_TOTAL FROM ORDER
WHERE ORDER ID = X
SELECT * FROM ORDER_LINE_ITEM
WHERE ORDER_ID = X
END TXN
Verify minimum is met
BEGIN TXN
Consumer - Sam
BEGIN TXN
SELECT ORDER_TOTAL FROM ORDER
WHERE ORDER ID = X
SELECT * FROM ORDER_LINE_ITEM
WHERE ORDER_ID = X
END TXN
UPDATE ORDER_LINE_ITEM
SET VERSION=..., QUANTITY=...
WHERE VERSION = <loaded version>
AND ID = ...
END TXN
Verify minimum is met
BEGIN TXN
UPDATE ORDER_LINE_ITEM
SET VERSION=..., QUANTITY=...
WHERE VERSION = <loaded version>
AND ID = ...
END TXN
Каждый клиент изменяет позицию заказа, используя последовательность из двух
транзакций. Первая транзакция загружает заказ и его позиции. Затем пользователь
ский интерфейс проверяет, достигнута ли минимальная сумма заказа. После этого
вторая транзакция обновляет количество единиц в заданной позиции, задействуя
проверку с оптимистичной автономной блокировкой, чтобы убедиться в неизмен
ности позиции заказа с момента его загрузки первой транзакцией.
194 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
В этом сценарии Сэм уменьшает общую сумму заказа на X долларов, а Мэри — на
Y долларов. В итоге заказ оказывается недействительным, хотя после обновления
каждым из клиентов приложение подтвердило, что сумма заказа не опустилась ниже
минимальной отметки. Как видите, обновление частей бизнес-объекта напрямую
может вылиться в нарушение бизнес-правил. Агрегаты из состава DDD призваны
решить эту проблему.
5.2.2. Агрегаты имеют четкие границы
Агрегат — это кластер доменных объектов, с которыми можно обращаться как с еди
ным целым. Он состоит из корневой сущности и иногда одной или нескольких сущ
ностей и объектов значений. Многие бизнес-объекты моделируются в виде агрегатов.
Например, в главе 2 мы создали доменную модель общего вида, проанализировав
имена существительные, которые использовались в бизнес-требованиях и среди
специалистов в данной области. Многие из этих имен существительных, такие как
Order, Consumer и Restaurant, являются агрегатами.
Агрегат Order и его границы показаны на рис. 5.5. Он состоит из сущности Order
и одного или нескольких объектов значений, таких как OrderLineltem, Address
и Paymentinformation.
Рис. 5.5. Структурирование доменной модели в виде набора агрегатов создает четкие границы
5.2. Проектирование доменной модели с помощью шаблона «Агрегат» из DDD 195
Агрегаты разбивают доменную модель на блоки, в которых легче разобраться
по отдельности. Они также проясняют область применения операций, таких как
загрузка, обновление и удаление. Эти операции распространяются на весь агрегат,
а не на какие-то его части. Агрегат часто загружается из базы данных целиком, что
позволяет избежать любых проблем с ленивой загрузкой. Вместе с агрегатом из базы
данных удаляются все его объекты.
Агрегаты — это границы согласованности
Обновление целого агрегата, а не отдельных его частей решает проблемы с согла
сованностью, подобных описанным в предыдущем примере. Операции обновления
вызываются для корня агрегата, который обеспечивает соблюдение инвариантов.
Кроме того, чтобы поддерживать конкурентность, корень агрегата блокируется
с помощью, скажем, номера версии или блокировки уровня базы данных. Напри
мер, вместо непосредственного обновления количества единиц для определенных
позиций клиент должен вызвать метод из корня агрегата Order, который следит за
соблюдением таких инвариантов, как минимальная сумма заказа. Однако следует
упомянуть, что этот подход не требует обновления всего агрегата в базе данных.
Приложение может, к примеру, обновить поля, относящиеся к заказу Order и из
мененному объекту OrderLineltem.
Главное — определить агрегаты
В DDD ключевым аспектом проектирования доменной модели является опре
деление агрегатов, их границ и корней. Детали внутренней структуры агрегатов
вторичны. Однако преимущества этого подхода далеко не ограничены разделением
доменной модели на модули. Причина этого в том, что агрегаты обязаны придержи
ваться определенных правил.
5.2.3. Правила для агрегатов
DDD требует, чтобы агрегаты подчинялись набору правил. Это делает агрегат авто
номной единицей, способной обеспечивать соблюдение инвариантов. Рассмотрим
эти правила.
Правило 1. Ссылайтесь только на корень агрегата
Предыдущий пример проиллюстрировал риски прямого обновления объекта
Order Lineitems. Первое правило призвано устранить эту проблему. Оно требует,
чтобы корневая сущность была единственной частью агрегата, на которую могут
ссылаться внешние классы. Для обновления агрегата клиенту необходимо вызвать
метод из его корня.
196 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
Например, сервис использует репозиторий, чтобы загрузить агрегат из базы дан
ных и получить ссылку на его корень. С помощью метода, вызываемого из корня,
он обновляет агрегат. Это правило гарантирует, что агрегат способен обеспечивать
соблюдение своих инвариантов.
Правило 2. Межагрегатные ссылки
должны применять первичные ключи
I [равило состоит в том, что агрегаты ссылаются друг на друга по уникальному зна
чению, например по первичному ключу, а не по объектным ссылкам. На рис. 5.6
показано, как заказ ссылается на своего заказчика с помощью consumerld, а не
ссылки на объект Consumer. Аналогичным образом заказ ссылается на ресторан с ис
пользованием restaurantld.
Рис. 5.6. Агрегаты ссылаются друг на друга с помощью первичных ключей, а не объектных ссылок.
У агрегата Order есть идентификаторы агрегатов Consumer и Restaurant. Внутри одного агрегата
объекты могут ссылаться друг на друга
Этот подход заметно отличается от традиционного моделирования объектов,
с точки зрения которого использование внешних ключей в доменной модели —
плохое архитектурное решение. Данный метод имеет ряд преимуществ. Отказ от
объектных ссылок в пользу уникальных идентификаторов означает, что агрегаты
слабо связаны между собой. Это позволяет четко определить границы между ними
и избежать случайного обновления не того агрегата. К тому же нам не нужно бес
покоиться об объектных ссылках, которые выходят за пределы одного сервиса.
Этот подход также упрощает сохранение состояния, поскольку агрегат является
единицей хранения. Благодаря этому агрегаты становится легче хранить в базах
данных NoSQL, таких как MongoDB. К тому же это устраняет необходимость в про
зрачной ленивой загрузке и проблемы, которые с ней связаны. Масштабирование
базы данных путем сегментирования агрегатов — довольно простая задача.
5.2. Проектирование доменной модели с помощью шаблона «Агрегат» из DDD 197
Правило 3. Одна транзакция создает или обновляет один агрегат
Еще одно правило, которому должны подчиняться агрегаты, состоит в том, что
транзакция может создать или обновить только один агрегат. Когда я впервые
прочитал о нем много лет назад, оно показалось мне бессмысленным! В то время
я занимался разработкой традиционных монолитных приложений с использовани
ем СУРБД, поэтому в моем случае транзакции могли обновлять множественные
агрегаты. Но в наши дни это ограничение идеально подходит для микросервисной
архитектуры. Оно гарантирует, что транзакция не выйдет за пределы сервиса.
А также хорошо согласуется с ограниченной транзакционной моделью большинства
баз данных NoSQL.
Это правило усложняет реализацию операций, которым нужно создавать или об
новлять несколько агрегатов. Но это явно одна из тех проблем, для решения которых
предназначены повествования, описанные в главе 4. Каждый этап повествования
создает или обновляет ровно один агрегат. На рис. 5.7 показано, как это работает.
Рис. 5.7. Транзакция может создать или обновить только один агрегат, поэтому для обновления
нескольких агрегатов приложение использует повествования. Каждый этап повествования создает
или обновляет один агрегат
В этом примере повествование состоит из трех транзакций. Первая транзакция
обновляет агрегат X в сервисе А. Две другие происходят в сервисе В: одна транзакция
обновляет агрегат У, а другая — агрегат Z.
Есть и альтернативный вариант. Чтобы обеспечить согласованность между не
сколькими агрегатами внутри одного сервиса, мы могли бы «сжульничать» и об
новить все эти агрегаты в рамках одной транзакции. Например, одной транзакции
могло бы быть достаточно для обновления агрегатов У и Z в сервисе В. Но это воз
можно только в СУРБД с развитой транзакционной моделью. Если вы применяете
базу данных NoSQL, которая поддерживает только простые транзакции, у вас нет
другого варианта, кроме как использовать повествования.
Но так ли это? Оказывается, границы между агрегатами не высечены в камне.
При разработке доменной модели вы сами можете определить, где они должны
проходить. Но, помня о том, как колониальные державы чертили государственные
границы в прошлом веке, вы должны подходить к этому осторожно.
198 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
5.2.4. Размеры агрегатов
При разработке доменной модели важно решить, насколько большим вы хотите
сделать тот или иной агрегат. С одной стороны, в идеале агрегаты должны быть
мелкими. Это увеличит количество одновременных запросов, которые может об
работать ваше приложение, и улучшит масштабируемость, поскольку обновления
каждого агрегата сериализуются. Это также положительно скажется на опыте взаи
модействия, так как снижается вероятность того, что два пользователя попытаются
внести конфликтующие изменения в один и гот же агрегат. Но с другой стороны,
агрегат — это область применения транзакции, поэтому, чтобы обеспечить атомар
ность определенного обновления, иногда стоит сделать его более крупным.
Ранее я упоминал о том, что в доменной модели приложения FTGO Order
и Consumer являются отдельными агрегатами. В качестве альтернативы мы могли бы
сделать Order частью Consumer (рис. 5.8).
Рис. 5.8. Альтернативное решение описывает агрегат Customer, который включает в себя
классы Customer и Order. Такой подход позволяет приложению атомарно обновлять заказчика
и один или несколько его заказов
Преимущество этого более крупного агрегата Consumer заключается в том, что
приложение может атомарно обновлять заказчика и один или несколько его заказов.
Недостатком этого подхода является ухудшение масштабируемости. Транзакции,
обновляющие разные заказы для одного клиента, будут сериализованы. К тому же,
если два пользователя попытаются отредактировать разные заказы одного клиента,
получится конфликт.
Еще одним отрицательным аспектом крупных агрегатов в контексте микросер
висной архитектуры является то, что они препятствуют декомпозиции. Например,
бизнес-логика для заказов и клиентов должна находиться в одном сервисе, что де
лает этот сервис более объемным. Учитывая эти проблемы, агрегаты лучше делать
как можно более мелкими.
5.2. Проектирование доменной модели с помощью шаблона «Агрегат» из DDD 199
5.2.5. Проектирование бизнес-логики
с помощью агрегатов
В типичном (микро)сервисе основная часть бизнес-логики состоит из агрегатов.
Остальной код принадлежит доменным сервисам и повествованиям. Повествования
оркестрируют цепочки локальных транзакций, чтобы обеспечить согласованность
данных. Сервисы служат точками входа в бизнес-логику и вызываются входящими
адаптерами. Сервис использует репозиторий для извлечения агрегатов или их со
хранения в базу данных. Каждый репозиторий реализуется исходящим адаптером,
который обращается к БД. Структура бизнес-логики сервиса Order на основе агре
гатов показана на рис. 5.9.
Рис. 5.9. Структура бизнес-логики сервиса Order на основе агрегатов
Бизнес-логика состоит из агрегата Order, класса сервиса OrderService, репо
зитория OrderRepository и одного или нескольких повествований. OrderService
200 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
обращается к OrderRepository, чтобы сохранять и загружать заказы. Если сервис
получает простые запросы, которые не выходят за его рамки, он обновляет агрегат
Order. Если запрос охватывает несколько сервисов, OrderService создает повество
вание, как было описано в главе 4.
Прежде чем переходить к коду, рассмотрим концепцию, тесно связанную с агре
гатами, — доменные события.
5.3. Публикация доменных событий
В толковом словаре Merriam-Webster (https://www.merriam-webster.com/dictionary/event)
перечислено несколько определений слова «событие», включая следующие:
□ что-то, что случается;
□ примечательный случай;
□ социальный повод или деятельность.
В контексте DDD доменное событие — это нечто произошедшее с агрегатом.
В доменной модели оно имеет вид класса. Событие обычно представляет изменение
состояния. Возьмем, к примеру, агрегат Order в приложении FTGO. В число собы
тий, которые меняют его состояние, входят Order Created, Order Cancelled, Order
Shipped и т. д. При наличии заинтересованных потребителей агрегат Order может
публиковать одно из этих событий в момент изменения своего состояния.
5.3.1. Зачем публиковать события
об изменениях
Полезность доменных событий связана с тем, что другие стороны взаимодействия
(пользователи, внешние приложения или другие компоненты внутри того же при
ложения) часто заинтересованы в информации об изменениях в состоянии агрегата.
Далее приведены несколько примеров.
□ Обеспечение согласованности между сервисами с помощью повествований на
основе хореографии, описанных в главе 4.
□ Уведомление сервиса, который хранит реплику, об изменении в источнике дан
ных. Этот подход, рассмотренный в главе 7, известен как разделение ответствен
ности командных запросов (CQRS).
□ Уведомление другого приложения через зарегистрированный веб-хук или брокер
сообщений, чтобы инициировать следующий этап в бизнес-процессе.
5.3. Публикация доменных событий 201
□ Уведомление другого компонента того же приложения, чтобы, к примеру, послать
браузеру пользователя сообщение через WebSocket или обновить текстовую базу
данных, такую как ElasticSearch.
□ Рассылка пользователям уведомлений (текстовых сообщений или электронных
писем) об отправке их заказа, готовности медицинского рецепта или задержке
самолета.
□ Мониторинг доменных событий для проверки корректности поведения системы.
□ Анализ событий для моделирования поведения пользователя.
Во всех этих сценариях причиной уведомления служит изменение состояния
агрегата в базе данных приложения.
5.3.2. Что такое доменное событие
Доменное событие — это класс с именем на основе страдательного причастия про
шедшего времени. Он содержит свойства, которые выразительно передают это со
бытие. Каждое свойство представляет собой либо простое значение, либо объект.
Например, класс события OrderCreated содержит свойство orderld.
У доменного события обычно есть метаданные, такие как его идентификатор
и временная метка. Оно может нести в себе идентификатор пользователя, который
сделал изменение, поскольку это полезно для аудита. Метаданные могут быть частью
объекта события — возможно, определенные в родительском классе. Или же они
могут находиться внутри обертки вокруг объекта события. Идентификатор агре
гата, который сгенерировал событие, тоже может быть не его непосредственным
свойством, а частью обертки.
OrderCreated является примером доменного события. У него нет никаких полей,
поскольку идентификатор заказа входит в состав обертки. В листинге 5.1 показаны
класс DomainEventEnvelope и класс события OrderCreated.
Листинг 5.1. Событие OrderCreated и класс DomainEventEnvelope
interface DomainEvent {}
interface OrderDomainEvent extends DomainEvent {}
class OrderCreated implements OrderDomainEvent {}
class DomainEventEnvelope<T extends DomainEvent> {
private String aggregateType;
private Object aggregateld;
private T event;
] Метаданные-событмя
}
DomainEvent — это интерфейс-маркер, который определяет класс как домен
ное событие. OrderDomainEvent — это интерфейс-маркер для таких событий, как
OrderCreated, которые публикуются агрегатом Order. DomainEventEnvelope — это
класс, который содержит объект события и его метаданные. Это обобщенный класс,
параметризированный типом доменного события.
202 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
5.3.3. Обогащение события
Допустим, вы пишете потребитель событий, который обрабатывает события Order.
Класс события OrderCreated, показанный ранее, отражает суть произошедшего.
Но при обработке события OrderCreated вашему потребителю могут понадобиться
подробности о заказе. Он может извлечь эту информацию из класса OrderService.
Но недостатком запрашивания агрегата у сервиса являются накладные расходы на
выполнение этого запроса.
В качестве альтернативы можно использовать обогащение событий — это когда
события содержат информацию, которая нужна потребителю. В результате потре
бители событий становятся более простыми, поскольку им больше не нужно запра
шивать данные у сервиса, опубликовавшего событие. Агрегат Order может обогатить
событие OrderCreated, включив в него информацию о заказе. Обогащенный вариант
OrderCreated показан в листинге 5.2.
Листинг 5.2. Обогащенное событие OrderCreated
class OrderCreated implements OrderEvent {
private List<OrderLineItem> lineitems;
private Deliveryinformation deliveryinformation;
private Paymentinformation paymentinformation;
private long restaurantld;
private String restaurantName;
Данные, которые обычно
необходимы потребителям
}
Эта версия события OrderCreated содержит подробности о заказе, поэтому потре
бителям, таким как OrderHistoryService из главы 7, больше не нужно запрашивать
данные при его обработке.
Обогащение событий упрощает потребителей, но его обратной стороной являет
ся риск снижения стабильности классов событий. Эти классы потенциально могут
меняться каждый раз, когда обновляются требования их потребителей. Это способно
отрицательно сказаться на поддерживаемое™, поскольку изменения такого рода мо
гут затронуть несколько частей приложения. К тому же удовлетворение требований
каждого потребителя может оказаться недостижимой целью. К счастью, во многих
ситуациях довольно легко определить, какие свойства следует включить в событие.
Теперь, рассмотрев основы доменных событий, мы можем поговорить о том, как
их обнаруживать.
5.3.4. Определение доменных событий
Есть несколько разных стратегий для определения доменных событий. Часто
сценарии, в которых необходимы уведомления, описываются в требованиях к при
ложению. Требования могут быть сформулированы так: «Если произойдет X, сде
лайте У». Например, вот одно из требований к приложению FTGO: «Если размещен
заказ, отправьте клиенту электронное письмо». Это указывает на существование
доменного события.
5.3. Публикация доменных событий 203
Еще один подход, который набирает популярность, называется событийным
штурмом. Событийный штурм — это формат собрания для обсуждения сложного
домена, сосредоточенный вокруг событий. Специалисты в предметной области соби
раются в комнате с большой доской для рисования или рулоном бумаги, куда будут
крепиться небольшие записки. Результатом этого процесса становится событийная
доменная модель, состоящая из агрегатов и событий.
Событийный штурм имеет три этапа.
1. Интенсивное определение событий. Специалистов в проблемной области просят
определить доменные события. Это будет выглядеть как цепочка из оранжевых
записок, выложенная на поверхности для моделирования.
2. Определение причин событий. Специалистов в проблемной области просят опре
делить причину каждого события, которая может быть одной из следующих:
• действие пользователя (представлено в виде команды на синей записке);
• внешняя система (фиолетовая записка);
• другое доменное событие;
• истечение времени.
3. Определение агрегатов. Специалистов в проблемной области просят определить
агрегат, который потребляет каждую команду и генерирует соответствующее со
бытие. Агрегаты представлены в виде желтых записок.
Результат собрания с событийным штурмом показан на рис. 5.10. Всего за не
сколько часов участники определили множество доменных событий, команд и агре
гатов. Это был отличный первый шаг на пути к созданию доменной модели.
Рис. 5.10. Результат собрания с событийным штурмом, которое длилось пару часов. Записки, которые
здесь видны, — это события, выложенные вдоль временного графика, команды, представляющие
действия пользователей, и агрегаты, которые генерируют события в ответ на команды
204 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
Событийный штурм помогает быстро разработать доменную модель.
Разобравшись с доменными событиями, мы можем приступить к механизму их
генерации и публикации.
5.3.5. Генерация и публикация
доменных событий
Взаимодействие с помощью доменных событий — это разновидность асинхронного
обмена сообщениями, который мы обсудили в главе 3. Но прежде, чем бизнес-логика
сможет опубликовать событие для брокера сообщений, она должна его создать.
Посмотрим, как это делается.
Генерация доменных событий
На концептуальном уровне доменные события публикуются агрегатами. Агрегат
знает, когда меняется его состояние и, следовательно, какое событие нужно опубли
ковать. Агрегат может обращаться непосредственно к API для обмена сообщениями.
Недостаток этого подхода связан с тем, что агрегат не поддерживает внедрение за
висимостей, из-за чего API для обмена сообщениями придется передавать в метод
в виде аргумента. Это приведет к переплетению инфраструктуры и бизнес-логики,
что крайне нежелательно.
Более подходящим подходом будет разделение ответственности между агрегатом
и сервисом (или эквивалентным классом), который к нему обращается. Сервисы
могут использовать внедрение зависимостей для получения ссылки на API для
обмена сообщениями, что позволяет им легко публиковать события. Агрегат генери
рует события при изменении своего состояния и возвращает их сервису (последняя
часть может быть реализована несколькими способами). Один из вариантов —
включить в значение, возвращаемое методом агрегата, список событий. Например,
в листинге 5.3 показано, как метод accept() агрегата Ticket возвращает событие
TicketAcceptedEvent вызывающей стороне.
Листинг 5.3. Метод accept() агрегата Ticket
public class Ticket {
public List<DomainEvent> accept(ZonedDateTime readyBy) {
this.acceptTime = ZonedDateTime.now(); ◄------------- Обновляем Ticket
this.readyBy = readyBy;
return singletonList(new TicketAcceptedEvent(readyBy)); ◄—. жу os r | Возвращаем событие
}
Сервис вызывает метод из корня агрегата и затем публикует события. Напри
мер, в листинге 5.4 показано, как KitchenService публикует события после вызова
Ticket.accept().
5.3. Публикация доменных событий 205
Листинг 5.4. KitchenService вызывает Ticket.accept()
public class KitchenService {
gAutowired
private TicketRepository ticketRepository;
gAutowired
private DomainEventPublisher domainEventPublisher;
public void accept(long ticketld, ZonedDateTime readyBy) {
Ticket ticket =
ticketRepository.findByld(ticketld)
.orElseThrow(() ->
new TicketNotFoundException(ticketld));
List<DomainEvent> events = ticket.accept(readyBy);
domainEventPublisher.publish(Ticket.class, orderld, events); <
}
Публикует
доменные
события
Вначале метод accept () обращается к TicketRepository, чтобы загрузить Ticket
из базы данных. Затем он обновляет Ticket, вызывая другой метод accept(). После
этого KitchenService публикует события, возвращенные агрегатом Ticket, вызывая
метод DomainEventPublisher.publish(), который мы вскоре рассмотрим.
Это довольно простой подход. Методы, которые прежде имели бы пустой тип
возвращаемого значения, теперь возвращают List<Event>. Единственный потенци
альный недостаток связан с тем, что возвращаемые типы непустых методов стали
сложнее. Пример такого метода вы увидите чуть позже.
Еще один вариант — накапливать события в поле корня агрегата. После этого
сервис извлекает эти события и публикует их. В листинге 5.5 показана разновид
ность класса Ticket, которая работает по этому принципу.
Листинг 5.5. Ticket наследует абстрактный класс, который записывает доменные события
public class Ticket extends AbstractAggregateRoot {
public void accept(ZonedDateTime readyBy) {
this.acceptTime = ZonedDateTime.now();
this.readyBy = readyBy;
registerDomainEvent(new TicketAcceptedEvent(readyBy));
}
}
Ticket наследует класс AbstractAggregateRoot, в котором определен метод
registerDomainEvent(), записывающий события. Для извлечения этих событий
сервису нужно сделать вызов AbstractAggregateRoot.getDomainEvents().
Лично я отдаю предпочтение первому варианту, в котором метод возвращает
события сервису. Но накапливание событий в корне агрегата — тоже жизнеспо
собный подход. Так называемый поезд релиза (release train) Spring Data Ingalls
(spring.io/blog/2017/01/30/what-s-new-in-spring-data-release-ingalls) реализует механизм,
206 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
который автоматически публикует события в контексте приложения Spring. Основной
недостаток этой методики в том, что для уменьшения дублирования кода корни агрега
тов должны наследовать такой родительский класс, как AbstгаctAggregateRoot, а это
может противоречить требованиям к наследованию каких-нибудь других классов.
Есть еще одна проблема: вызывать registerDomainEvent() из методов корня агрегата
довольно удобно, однако делать это из других классов агрегата оказывается не так
просто. В большинстве случаев для этого пришлось бы передавать события в корень.
Надежная публикация доменных событий
В главе 3 обсуждалась надежная отправка сообщений в рамках локальных транзак
ций базы данных. Доменные события ничем в этом смысле не отличаются. Чтобы
события публиковались как часть транзакции, которая обновляет агрегат в базе
данных, сервис должен использовать транзакционный обмен сообщениями. Такой
механизм реализован в фреймворке Eventuate Tram, описанном в главе 3. Он встав
ляет события в таблицу OUTBOX в рамках ACID-транзакции, которая обновляет базу
данных. После фиксации транзакции события, вставленные в таблицу OUTBOX, пу
бликуются для брокера сообщений.
Фреймворк Eventuate Tram предоставляет интерфейс DomainEventPublisher,
показанный в листинге 5.6. Он определяет несколько перегруженных методов
publish(), которые принимают в качестве параметров тип и ID агрегата, а также
список доменных событий.
Листинг 5.6. Интерфейс DomainEventPublisher из фреймворка Eventuate Tram
public interface DomainEventPublisher {
void publish(String aggregateType, Object aggregateld,
List<DomainEvent> domainEvents);
Для транзакционной публикации этих событий здесь используется интерфейс
Messageproducer из состава Eventuate Tram.
Сервис мог бы вызывать DomainEventPublisher напрямую. Но так у нас не было бы
способа гарантировать, что все публикуемые события действительны. Например,
сервис KitchenService должен публиковать только события, которые реализуют
TicketDomainEvent — интерфейс-маркер для событий агрегата Ticket. Вместо этого
сервису лучше реализовать подкласс AbstractAggregateDomainEventPublisher, по
казанный в листинге 5.7, — абстрактный класс, который предоставляет типобезо
пасный интерфейс для публикации доменных событий. Этот класс также является
обобщенным и имеет два типизированных параметра: А (тип агрегата) и Е (тип
интерфейса-маркера для доменных событий). Сервис публикует события путем
вызова метода publish (), который принимает два параметра: агрегат типа А и список
событий типа Е.
Листинг 5.7. Родительский класс типобезопасных издателей событий
public abstract class AbstractAggregateDomainEventPublisher<A, E extends
DomainEvent> {
5.3. Публикация доменных событий 207
private Function<A, Object> idSupplier;
private DomainEventPublisher eventPublisher;
private Class<A> aggregateType;
protected AbstractAggregateDomainEventPublisher(
DomainEventPublisher eventPublisher,
Class<A> aggregateType,
Function<A, Object> idSupplier) {
this.eventPublisher = eventpublisher;
this.aggregateType = aggregateType;
this.idSupplier = idSupplier;
}
public void publish(A aggregate, List<E> events) {
eventPublisher.publish(aggregateType, idSupplier.apply(aggregate),
(List<DomainEvent>) events);
}
}
Метод publish() извлекает ID агрегата и вызывает DomainEventPublisher.pub-
lish(). В листинге 5.8 показан класс TicketDomainEventPublisher, который публи
кует доменные события для агрегата Ticket.
Листинг 5.8. Типобезопасный интерфейс для публикации доменных событий агрегата Ticket
public class TicketDomainEventPublisher extends
AbstractAggregateDomainEventPublisher<Ticket, TicketDomainEvent> {
public TicketDomainEventPublisher(DomainEventPublisher eventPublisher) {
super(eventPublisher, Ticket.class, Ticket::getld);
}
}
Этот класс публикует только события, наследованные от TicketDomainEvent.
Итак, мы разобрались с тем, как публиковать доменные события. Теперь посмо
трим, как их потреблять.
5.3.6. Потребление доменных событий
В конечном счете доменные события доходят до брокера (например, Apache Kafka)
в виде сообщений. Потребитель может напрямую воспользоваться клиентским
API брокера. Но удобнее использовать API, такой как DomainEventDispatcher из
состава фреймворка Eventuate Tram (см. главу 3). DomainEventDispatcher направ
ляет доменные события к подходящему методу-обработчику. Пример класса для
обработки событий показан в листинге 5.9. Класс KitchenServiceEventConsumer
подписывается на события, публикуемые сервисом Restaurant при каждом об
новлении меню ресторана. Он отвечает за поддержание в актуальном состоянии
реплики сервиса Kitchen.
208 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
Листинг 5.9. Передача событий методам-обработчикам
public class KitchenServiceEventConsumer {
@Autowired
private Restaurantservice restaurantservice:
Сопоставляет события
public DomainEventHandlers domainEventHandlers() { ◄— cобработчиками
return DomainEventHandlersBuilder
.forAggregateType("net.chrisrichardson.ftgo.restaurantservice.Restaurant")
.onEvent(RestaurantMenuRevised.class, this::reviseMenu)
.build();
}
public void reviseMenu(DomainEventEnvelope<RestaurantMenuRevised> de) { ◄—
long id = Long.parseLong(de.getAggregateId());
RestaurantMenu revisedMenu = de.getEvent().getRevisedMenu();
restaurantservice.reviseMenu(id, revisedMenu);
у Обработчик для событий
RestaurantMenuRevised
}
Метод reviseMenu() обрабатывает события RestaurantMenuRevised. Он вызывает
метод restaurantService.reviseMenu(), который обновляет меню ресторана и воз
вращает список доменных событий, опубликованных обработчиком.
Обсудив агрегаты и доменные события, мы можем перейти к некоторым при
мерам бизнес-логики, реализованной на основе агрегатов.
5.4. Бизнес-логика сервиса Kitchen
Первым примером будет сервис Kitchen, который позволяет ресторану управлять
своими заказами. Два основных агрегата этого сервиса — Restaurant и Ticket.
Первый умеет проверять заказы и знает меню ресторана и его время работы, а вто
рой представляет собой заказ, который ресторан должен подготовить для доставки
курьером. Эти агрегаты и другие части бизнес-логики сервиса, включая его адап
теры, показаны на рис. 5.11.
Помимо агрегатов, важными элементами бизнес-логики сервиса Kitchen явля
ются классы KitchenService, TicketRepository и RestaurantRepository. Kit-
сhenService — это точка входа в бизнес-логику. Она определяет методы для соз
дания и обновления агрегатов Restaurant и Ticket. Классы TicketRepository
и RestaurantRepository определяют методы для сохранения экземпляров Ticket
и Restaurant соответственно.
У сервиса Kitchen есть три входящих адаптера:
□ REST API — REST API, к которому через пользовательский интерфейс обращаются
работники ресторана. Он вызывает KitchenService для создания и обновления
заявок;
5.4. Бизнес-логика сервиса Kitchen 209
Рис. 5.11. Структура сервиса Kitchen
KitchenServiceCommandHandler — API, основанный на асинхронных запросах/от-
ветах, который вызывается повествованиями. Для создания и обновления заявок
он обращается к KitchenService;
KitchenServiceEventConsumer — подписывается на события, публикуемые сер
висом Restaurant. Он вызывает KitchenService для создания и обновления
экземпляров Restaurant.
У этого сервиса есть также два исходящих адаптера:
адаптер БД — реализует интерфейсы TicketRepository и RestaurantRepository,
обращается к базе данных;
DomainEventPublishingAdapter — реализует интерфейс DomainEventPublisher
и публикует доменные события Ticket.
210 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
Давайте поближе познакомимся со структурой KitchenService, начиная с агре
гата Ticket.
5.4.1. Агрегат Ticket
Ticket — это один из агрегатов сервиса Kitchen. Как упоминалось в главе 2 при
обсуждении изолированного контекста, этот агрегат является представлением
заказа с точки зрения кухни ресторана. Он не содержит никакой информации
о заказчике — ни его идентификатора, ни адреса доставки, ни платежных данных.
Он сосредоточен на том, чтобы помочь кухне ресторана подготовить заказ для
доставки курьером. Более того, сервис Kitchen не генерирует уникальный ID для
этого агрегата. Вместо этого он использует идентификатор, предоставленный
сервисом Order.
Сначала взглянем на структуру этого класса, а затем рассмотрим его методы.
Структура класса Ticket
В листинге 5.10 показан фрагмент кода из этого класса. Ticket похож на традицион
ный доменный класс. Его основная особенность — то, что ссылки на другие агрегаты
выполнены в виде первичных ключей.
Листинг 5.10. Фрагмент класса Ticket, который является сущностью JPA
@Entity(table=”tickets”)
public class Ticket {
@Id
private Long id;
private Ticketstate state;
private Long restaurantld;
@ElementCollection
@CollectionTable(name="ticket_line_items")
private List<TicketLineItem> lineitems;
private ZonedDateTime
private ZonedDateTime
private ZonedDateTime
private ZonedDateTime
private ZonedDateTime
readyBy;
acceptTime;
preparingTime;
pickedUpTime;
readyForPickupTime;
Этот класс сохраняется с помощью JPA и накладывается на таблицу TICKETS.
Поле restaurantld имеет тип Long и не является объектной ссылкой на Restaurant.
Поле readyBy хранит примерное время готовности заказа к отправке. У класса Ticket
есть несколько полей, которые отслеживают историю заказа, включая acceptTime,
preparingTime и pickupTime. Обсудим эти методы.
5.4. Бизнес-логика сервиса Kitchen 211
Поведение агрегата Ticket
Агрегат Ticket определяет несколько методов. Как вы видели ранее, у него есть ста
тический метод create(), который является фабрикой для создания заявок. Есть так
же методы, которые вызываются, когда ресторан обновляет состояние заказа:
□ accept () — ресторан принял заказ;
□ preparing() — ресторан начал подготовку заказа. Это означает, что заказ больше
нельзя изменить или отменить;
□ readyForPickup() — заказ готов к отправке.
Некоторые из методов Ticket показаны в листинге 5.11.
Листинг 5.11. Некоторые из методов класса Ticket
public class Ticket {
public static ResultWithAggregateEvents<Ticket, TicketDomainEvent>
create(Long id, TicketDetails details) {
return new ResultWithAggregateEventso(new Ticket(id, details), new
TicketCreatedEvent(id, details));
}
public List<TicketPreparationStartedEvent> preparing() {
switch (state) {
case ACCEPTED:
this.state = Ticketstate.PREPARING;
this.preparingTime = ZonedDateTime.now();
return singletonList(new TicketPreparationStartedEvent());
default:
throw new UnsupportedStateTransitionException(state);
}
}
public List<TicketDomainEvent> cancel() {
switch (state) {
case CREATED:
case ACCEPTED:
this.state = Ticketstate.CANCELLED;
return singletonList(new TicketCancelled());
case READY_FOR_PICKUP:
throw new TicketCannotBeCancelledException();
default:
throw new UnsupportedStateTransitionException(state);
}
}
Метод create() создает заявку. Метод preparing() вызывается, когда ресторан
начинает подготовку заказа. Он меняет состояние заказа на PREPARING, записыва
ет время и публикует событие. Метод cancel() вызывается, когда пользователь
212 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
пытается отменить заказ. Если отмена возможна, он меняет состояние заказа и воз
вращает событие, в противном случае генерирует исключение. Эти методы вызы
ваются в ответ на запросы к REST API, а также события и командные сообщения.
Посмотрим, какие классы вызывают методы агрегата.
Доменный сервис KitchenService
Класс KitchenService вызывается входящими адаптерами сервиса. Он определяет
различные методы для изменения состояния заказа, включая accept(), reject(),
preparing() и др. Каждый метод загружает заданный агрегат, делает подходящий
вызов к корню агрегата и публикует все доменные события, которые возникли в про
цессе. В листинге 5.12 показан метод accept ().
Листинг 5.12. Метод сервиса accept() обновляет Ticket
public class KitchenService {
gAutowired
private TicketRepository ticketRepository;
gAutowired
private TicketDomainEventPublisher domainEventPublisher;
public void accept(long ticketld, ZonedDateTime readyBy) {
Ticket ticket =
ticketRepository.findByld(ticketld)
.orElseThrow(() ->
new TicketNotFoundException(ticketld));
}
List<TicketDomainEvent> events = ticket.accept(readyBy);
domainEventPublisher.publish(ticket> events);
Публикуем
доменные события
}
Метод accept () вызывается, когда ресторан принимает новый заказ. У него есть
два параметра:
□ orderld — идентификатор принимаемого заказа;
□ readyBy — предполагаемое время готовности заказа к доставке.
Этот метод извлекает агрегат Ticket, вызывает из него другой метод accept()
и публикует любые сгенерированные события.
Теперь рассмотрим класс, который обрабатывает асинхронные команды.
Класс KitchenServiceCommandHandler
Класс KitchenServiceCommandHandlei— это адаптер, который отвечает за обработку
командных сообщений, отправляемых различными повествованиями сервиса Order.
Для каждой команды этот класс определяет свой метод-обработчик, который об-
5.4. Бизнес-логика сервиса Kitchen 213
ращается к KitchenService для создания или обновления Ticket. Фрагмент этого
класса показан в листинге 5.13.
Листинг 5.13. Обработка командных сообщений, отправляемых повествованиями
public class KitchenServiceCoramandHandler {
@Autowired
private KitchenService kitchenService;
public CommandHandlers commandHandlers() { <■
return CommandHandlersBuilder
.fromChannel("orderservice”)
.onMessage(CreateTicket.class, this::CreateTicket)
.onMessage(ConfirmCreateTicket.class,
this::confirmCreateTicket)
.onMessage(CancelCreateTicket.class,
this::CancelCreateTicket)
.build();
}
Сопоставляет командные
сообщения с обработчиками
private Message CreateTicket(CommandMessage<CreateTicket>
cm) {
CreateTicket command = cm.getCommand();
long restaurantld = command.getRestaurantId();
Long ticketld = command.get0rderld();
TicketDetails ticketDetails =
command.getTicketDetails();
try {
Ticket ticket =
Обращается к KitchenService
для создания заявки
kitchenService.createTicket(restaurantld,
ticketld, ticketDetails);
CreateTicketReply reply =
new CreateTicketReply(ticket.getld());
return withSuccess(reply); ◄—
} catch (RestaurantDetailsVerificationException e) {
return withFailure(); ◄----------- . ж
I | Возвращает ответе отказом
}
private Message confirmCreateTicket
(CommandMessage<ConfirmCreateTicket> cm) { <-----
Long ticketld = cm.getCommand().getTicketId();
kitchenService.confirmCreateTicket(ticketld);
return withSuccess();
}
Возвращает успешный ответ
Подтверждает заказ
Все методы обработки команд обращаются к KitchenService и возвращают либо
успешный ответ, либо информацию об отказе.
Итак, вы познакомились с бизнес-логикой относительно простого сервиса.
Теперь рассмотрим более сложный пример — сервис Order.
214 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
5.5. Бизнес-логика сервиса Order
Как упоминалось в начальных главах, сервис Order предоставляет API для соз
дания, обновления и отмены заказов. Этот интерфейс в основном вызывается за
казчиком. На рис. 5.12 показана общая структура сервиса. Центральным агрегатом
OrderService является Order. Есть также агрегат Restaurant, который представляет
собой частичную копию данных, принадлежащих сервису Restaurant. Он позволяет
сервису Order проверять и оценивать позиции заказа.
Рис. 5.12. Структура сервиса Order. Он имеет REST API для управления заказами и обменивается
сообщениями и событиями с другими сервисами, используя несколько каналов сообщений
5.5. Бизнес-логика сервиса Order 215
Помимо агрегатов Order и Restaurant, бизнес-логика имеет классы OrderService,
OrderRepository и RestaurantRepository, а также повествования наподобие
CreateOrderSaga, которое было описано в главе 4. OrderService является основной
точкой входа в бизнес-логику и определяет методы для создания и обновления эк
земпляров Order и Restaurant. OrderRepository определяет методы для сохранения
заказов, а у RestaurantRepository есть методы для сохранения агрегатов Restaurant.
Сервис Order обладает несколькими входящими адаптерами.
□ REST API — REST API, к которому через пользовательский интерфейс обращаются
заказчики. Для создания и обновления заказов он вызывает OrderService.
□ OrderEventConsumer — подписывается на события, публикуемые сервисом
Restaurant. Он обращается к OrderService для создания и обновления своих
копий агрегатов Restaurant.
□ OrderCommandHandlers — API, основанный на асинхронных запросах/ответах,
который вызывается повествованиями. Для обновления заказов он обращается
к OrderService.
□ SagaReplyAdapter — обращается к повествованию, предварительно подписавшись
на канал его ответов.
У этого сервиса также есть исходящие адаптеры:
□ адаптер БД — реализует интерфейс OrderRepository и обращается к базе данных
OrderService;
□ DomainEventPublishingAdapter — реализует интерфейс DomainEventPublisher
и публикует доменные события Order;
□ OutboundCommandMessageAdapter — реализует интерфейс Commandpublisher и рас
сылает командные сообщения участникам повествования.
Сначала поближе рассмотрим агрегат Order, а затем исследуем OrderService.
5.5.1. Агрегат Order
Агрегат Order представляет собой заказ, размещенный клиентом. Вначале мы обсу
дим его структуру, а после этого перейдем к его методам.
Структура агрегата Order
Структура агрегата Order показана на рис. 5.13. Его корнем является класс
Order. В состав агрегата входят также объекты значений, такие как OrderLineltem,
Deliveryinfo и Paymentinfo.
У класса Order есть набор элементов OrderLineltems. К остальным двум своим
агрегатам, Consumer и Restaurant, он обращается по значению первичного ключа.
Внутри Order находятся еще два класса: Deliveryinfo, который хранит адрес и же
лательное время доставки, и Paymentinfo, содержащий информацию о платеже.
Код показан в листинге 5.14.
216 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
Рис. 5.13. Структура агрегата Order, который состоит из корня и различных объектов значений
Листинг 5.14. Класс Order и его поля
^Entity
@Table(name="orders")
@Access(AccessType.FIELD)
public class Order {
@Id
@GeneratedValue
private Long id;
^Version
private Long version;
private Orderstate state;
private Long consumerld;
private Long restaurantld;
^Embedded
private OrderLineltems orderLineltems;
^Embedded
private Deliveryinformation deliveryinformation;
^Embedded
private Paymentinformation paymentinformation;
^Embedded
private Money orderMinimum;
5.5. Бизнес-логика сервиса Order 217
Этот класс сохраняется с помощью JPA и накладывается на таблицу ORDERS.
Поле id служит первичным ключом. Поле version используется для оптимистич
ного блокирования. Состояние заказа представлено перечислением Orderstate.
Поля Deliveryinformation и Paymentinformation связываются с помощью аннотации
^Embedded и хранятся в виде столбцов таблицы ORDERS. Поле orderLineltems — это
встроенный объект, который содержит позиции заказа. Агрегат Order состоит более
чем из одного поля. Он также реализует бизнес-логику, которая может быть описана
конечным автоматом. Посмотрим, как это выглядит.
Конечный автомат агрегата Order
Для создания и обновления заказов сервис Order должен взаимодействовать с дру-
гими сервисами, используя повествования. Метод, который проверяет возможность
выполнения операции и меняет состояние заказа на PENDING, вызывается либо
самим сервисом Order, либо первым этапом повествования. Как объяснялось в гла
ве 4, состояние PENDING — это пример контрмеры под названием «семантическая
блокировка», которая помогает изолировать повествования друг от друга. После
обращения к сервисам-участникам повествование рано или поздно обновляет за
каз, чтобы отразить результат выполнения. Например, как было описано в главе 4,
у повествования есть несколько участников, включая сервисы Consumer, Accounting
и Kitchen. Изначально OrderService создает заказ с состоянием APPROVAL PENDING
и позже меняет это состояние на APPROVED или REJECTED. Такое поведение агрегата
Order можно смоделировать в виде конечного автомата (рис. 5.14).
Рис. 5.14. Часть модели конечного автомата для агрегата Order
Точно так же другие операции сервиса Order, такие как revise() и cancel(),
сначала меняют состояние заказа на PENDING, а затем используют повествование,
чтобы проверить возможность выполнения операции. Затем, если проверка прошла
218 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
успешно, состояние заказа меняется на такое, которое отражает успешное выпол
нение операции. Если проверка оказалась неудачной, возвращается предыдущее
состояние заказа. Например, операция cancel() вначале устанавливает заказ в со
стояние CANCEL-PENDING. Если заказ можно отменить, повествование Cancel Order
меняет это состояние на CANCELLED. В противном случае, если операция cancel()
отклоняется (скажем, уже слишком поздно для отмены заказа), заказ возвращается
обратно к состоянию APPROVED.
Теперь посмотрим, как агрегат Order реализует этот конечный автомат.
Методы агрегата Order
У класса Order есть несколько групп методов, каждая из которых относится к опре
деленному повествованию. Один из методов группы вызывается в самом начале
повествования, остальные — в конце. Начнем с обсуждения бизнес-логики, которая
создает заказ, после этого рассмотрим операцию его обновления. В листинге 5.15
показаны методы агрегата Order, вызываемые в процессе создания заказа.
Листинг 5.15. Методы, которые вызываются во время создания заказа
public class Order { ...
public static ResultWithDomainEvents<Order, OrderDomainEvent>
createOrder(long consumerld, Restaurant restaurant,
List<OrderLineItem> orderLineltems) {
Order order = new Order(consumerld, restaurant.getld(), orderLineltems);
List<OrderDomainEvent> events = singletonList(new OrderCreatedEvent(
new OrderDetails(consumerId, restaurant.getld(), orderLineltems,
order.getOrderTotalQ),
restaurant.getName()));
return new ResultWithDomainEventso(order, events);
}
public Order(OrderDetails orderDetails) {
this.orderLineltems = new OrderLineItems(orderDetails.getLineItems());
this.orderMinimum = orderDetails.getOrderMinimum();
this.state = APPROVAL-PENDING;
}
public List<DomainEvent> noteApproved() {
switch (state) {
case APPROVAL-PENDING:
this.state = APPROVED;
return singletonList(new OrderAuthorizedO);
default:
throw new UnsupportedStateTransitionException(state);
}
}
public List<DomainEvent> noteRejected() {
switch (state) {
5.5. Бизнес-логика сервиса Order 219
case APPROVAL-PENDING:
this.state = REJECTED;
return singletonList(new OrderRejected());
default:
throw new UnsupportedStateTransitionException(state);
}
}
createOrder() — это статический фабричный метод, который создает заказ и пу
бликует событие OrderCreatedEvent. Последнее обогащается подробностями о за
казе, включая его позиции, общую сумму, а также ID и название ресторана. В главе 7
мы поговорим о том, как сервис Order History поддерживает легкодоступную копию
Order, используя такие события, как OrderCreatedEvent.
Начальное состояние заказа равно APPROVAL-PENDING. Когда повествование Create
Order завершается, оно вызывает либо noteApproved(), либо noteRejected(). Метод
noteApproved() вызывается в случае успешной авторизации банковской карты кли
ента. Метод noteRejected() вызывается, когда один из сервисов отклоняет заказ или
авторизация оказывается неудачной. Как видите, поле state агрегата Order опреде
ляет поведение большинства его методов. Кроме того, агрегат Order, как и Ticket,
генерирует события.
Класс Order, помимо createOrder(), определяет несколько методов обновления.
Например, чтобы пересмотреть заказ, повествование Revise Order сначала вызывает
метод revise(), а затем, подтвердив возможность выполнения этой операции, ис
пользует метод conf irmRevised(). Эти методы показаны в листинге 5.16.
Листинг 5.16. Методы Order для пересмотра заказа
class Order ...
public List<OrderDomainEvent> revise(OrderRevision orderRevision) {
switch (state) {
case APPROVED:
LineltemQuantityChange change =
orderLineltems.lineltemQuantityChange(orderRevision);
if (change.newOrderTotal.isGreaterThanOrEqual(orderMinimum)) {
throw new OrderMinimumNotMetException();
}
this.state = REVISION-PENDING;
return singletonList(new OrderRevisionProposed(orderRevision,
change.currentOrderTotal, change.newOrderTotal));
default:
throw new UnsupportedStateTransitionException(state);
}
}
public List<OrderDomainEvent> confirmRevision(OrderRevision orderRevision) {
switch (state) {
case REVISION-PENDING:
LineltemQuantityChange lied =
220 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
orderLineltems.lineltemQuantityChange(orderRevision);
orderRevision
.getDeliverylnformation()
.ifPresent(newDi -> this.deliveryinformation = newDi);
if (!orderRevision.getRevisedLineItemQuantities() .isEmptyO) {
orderLineltems.updateLineltems(orderRevision);
}
this.state = APPROVED;
return singletonList(new OrderRevised(orderRevision,
lied.currentOrderTotalj lied.newOrderTotal));
default:
throw new UnsupportedStateTransitionException(state);
}
}
}
Метод revise() вызывается, чтобы инициировать пересмотр заказа. Помимо проче
го, он проверяет, не нарушит ли это правило о минимальной сумме, и меняет состояние
заказа на REVISION-PENDING. Успешно обновив сервисы Kitchen и Accounting, повество
вание Revise Order вызывает метод confirmRevision(), чтобы завершить пересмотр.
Эти методы вызываются классом OrderService. Рассмотрим его.
5.5.2. Класс OrderService
Класс OrderService определяет методы для создания и обновления заказов. Это глав
ная точка входа в бизнес-логику, к которой обращаются входящие адаптеры, такие
как REST API. Большинство методов этого класса формируют повествования для ор
кестрации создания и обновления агрегатов Order. В итоге этот сервис получается бо
лее сложным, чем класс KitchenService, который мы обсудили ранее. В листинге 5.17
показан фрагмент OrderService. В него внедряются разные зависимости, включая
OrderRepository, OrderDomainEventPublisher и несколько диспетчеров повествований.
Он определяет несколько методов, таких как createOrder() и reviseOrder().
Листинг 5.17. Методы класса OrderService для создания заказов и управления ими
^Transactional
public class OrderService {
@Autowired
private OrderRepository orderRepository;
gAutowired
private SagaManager<CreateOrderSagaState, CreateOrderSagaState>
createOrderSagaManager;
@Autowired
private SagaManager<ReviseOrderSagaState, ReviseOrderSagaData>
5.5. Бизнес-логика сервиса Order 221
reviseOrderSagaManagement;
gAutowired
private OrderDomainEventPublisher orderAggregateEventPublisher;
public Order createOrder(OrderDetails orderDetails) {
Restaurant restaurant = restaurantRepository.findByld(restaurantld)
.orElseThrow(() -
> new RestaurantNotFoundException(restaurantld));
List<OrderLineItem> orderLineltems =
_ _ _ _ _ _ | Создает агрегат Order
makeOrderLineItems(lineitems, restaurant);
ResultWithDomainEvents<Order, OrderDomainEvent> orderAndEvents =
Order.createOrder(consumerld, restaurant, orderLineltems);
Order order = orderAndEvents.result;
OrderRepository.save(order); ◄—
Сохраняет Order
в базу данных
Публикует
доменные
события
orderAggregateEventPublisher.publish(order, orderAndEvents.events); <■
OrderDetails orderDetails =
new OrderDetails(consumerId, restaurantld, orderLineltems,
order.getOrderTotal());
CreateOrderSagaState data = new CreateOrderSagaState(order.getId(),
orderDetails);
createOrderSagaManager.create(data, Order.class, order.getld()); <
return order;
}
Создает повествование
Create Order
public Order reviseOrder(Long orderld, Long expectedVersion,
OrderRevision orderRevision) {
public Order reviseOrder(long orderld, OrderRevision orderRevision) {
Order order = orderRepository.findByld(orderld) ◄--------
.orElseThrow(() -> new OrderNotFoundException(orderld));
ReviseOrderSagaData sagaData =
new ReviseOrderSagaData(order.getConsumerId(), orderld,
null, orderRevision);
reviseOrderSagaManager.create(sagaData);
return order;
}
}
Извлекает
Order
Создает повествование Revise Order
Метод createOrder () сначала создает и сохраняет агрегат Order, затем публикует
сгенерированные им доменные события. А в конце создает CreateOrderSaga. Метод
reviseOrder() извлекает Order и создает ReviseOrderSaga.
Бизнес-логика микросервисного и монолитного приложений не так уж сильно
различается. Она состоит из классов, таких как сервисы, сущности на основе JPA
222 Глава 5 • Проектирование бизнес-логики в микросервисной архитектуре
и репозитории. Но некоторые различия все же есть. Доменная модель микросервисов
организована в виде набора агрегатов из DDD, которые накладывают различные
архитектурные ограничения. В отличие от традиционной объектной модели, классы
в разных агрегатах ссылаются друг на друга с помощью значений первичных клю
чей, а не объектных ссылок. Кроме того, транзакции могут создавать и обновлять
только один агрегат. При изменении своего состояния агрегаты могут публиковать
доменные события, что довольно полезно.
Еще одно важное различие состоит в том, что для обеспечения согласованно
сти данных между несколькими сервисами часто используются повествования.
Например, сервис Kitchen всего лишь участвует в повествованиях, но никогда их
не инициирует. Для сравнения: сервис Order полностью полагается на повествова
ния при создании и обновлении заказов. Это вызвано тем, что заказы должны быть
транзакционно согласованными с данными, принадлежащими другим сервисам.
В итоге большинство методов класса OrderService не работают с заказами напрямую,
а создают повествования.
Из этой главы вы узнали, как реализовать бизнес-логику с помощью традици
онного подхода к сохранению данных. Сюда включена интеграция обмена сообще
ниями и публикации событий с управлением транзакциями базы данных. Код для
публикации событий переплетается с бизнес-логикой. В следующей главе мы по
говорим о шаблоне «Порождение событий», в котором генерация событий интегри
рована в бизнес-логику, а не «прикручена сбоку».
Резюме
□ Процедурный шаблон «Сценарий транзакции» часто является хорошим реше
нием для реализации простой бизнес-логики. Но, когда бизнес-логика усложня
ется, стоит подумать об использовании объектно-ориентированной доменной
модели.
□ Хороший способ организации бизнес-логики сервиса — ее разделение на агрегаты
по принципу DDD. Агрегаты делают доменную модель более модульной, исключа
ют возможность применения объектных ссылок между сервисами и гарантируют,
что каждая ACID-транзакция выполняется в рамках одного сервиса.
□ При создании или обновлении агрегат должен публиковать доменные события.
Эти события имеют множество сфер применения. В главе 4 вы могли видеть, как
они реализуют повествования с использованием хореографии. А в главе 7 мы
поговорим о том, как с их помощью можно обновлять реплицированные данные.
Подписчики на доменные события moijt уведомлять пользователей и другие при
ложения, а также публиковать сообщения в клиентском браузере через WebSocket.
Разработка бизнес-логики
с порождением событий
Мэри понравилась идея, состоящая в структурировании бизнес-логики в виде набора
агрегатов из DDD, которые публикуют доменные события (см. главу 5). Она могла
представить себе, насколько полезным будет использование этих событий в микро
сервисной архитектуре. Мэри планировала применять события для реализации по
вествований на основе хореографии, которые обеспечивают согласованность данных
между сервисами (см. главу 4). А также намеревалась задействовать представления
CQRS — реплики с поддержкой эффективных запросов (см. главу 7).
Однако она была немного обеспокоена тем, что логика публикации событий
может вызвать ошибки. С одной стороны, такая логика довольно проста: каждый
метод агрегата, который инициализирует или меняет его состояние, возвращает
список событий. Но с другой — она «прикручена сбоку» к бизнес-логике. Бизнес-
логика продолжит работать, даже если разработчик забыл опубликовать событие.
Мэри волновалась, что такой способ публикации событий может стать источником
ошибок.
Много лет назад Мэри узнала о порождении событий (event sourcing) — собы
тийном подходе к написанию бизнес-логики и сохранению доменных объектов.
224 Глава 6 • Разработка бизнес-логики с порождением событий
В то время ее заинтриговали многочисленные преимущества этой методики, вклю
чая сохранение полной истории изменений агрегата. Но это было не более чем лю
бопытство. Учитывая, насколько важную роль играют доменные события в микро
сервисной архитектуре, Мэри начала задумываться о возможности использования
порождения событий в приложении FTGO. В конце концов, этот шаблон проекти
рования устраняет источник программных ошибок, гарантируя, что события всегда
публикуются при создании и обновлении агрегата.
Я начну эту главу с описания принципа работы порождения событий и покажу,
как применять его для написания бизнес-логики. Вы узнаете, как этот шаблон со
храняет все события каждого агрегата внутри так называемого хранилища событий.
Мы взвесим преимущества и недостатки этого подхода и посмотрим, как реализо
вать вышеупомянутое хранилище. Вам будет представлен простой фреймворк для
написания бизнес-логики на основе порождения событий. После я расскажу о том,
почему этот шаблон — хорошая основа для реализации повествований. Начнем с рас
смотрения разработки бизнес-логики с помощью порождения событий.
6.1. Разработка бизнес-логики
с использованием порождения событий
Порождение событий — это еще один способ структурирования бизнес-логики и со
хранения агрегатов. Агрегаты сохраняются в виде последовательности событий,
каждое из которых представляет изменение состояния агрегата. Приложение вос
создает текущее состояние, воспроизводя записанные события.
Порождение событий имеет несколько важных преимуществ. Например, оно со
храняет историю агрегатов, которая может пригодиться для аудита или соблюдения
нормативно-правовых норм. Оно также выполняет надежную публикацию доменных
событий, что особенно полезно в микросервисной архитектуре. Но у этого шаблона
есть и недостатки. Он сложен в изучении, поскольку это совершенно другой способ
написания бизнес-логики. К тому же обращение к хранилищу событий часто затруд
нительно и требует использования шаблона CQRS, который описывается в главе 7.
Я начну раздел с описания ограничений, свойственных традиционной модели
сохранения данных. Затем мы подробно обсудим порождение событий и поговорим
о том, как оно преодолевает эти ограничения. После этого я покажу, как реализовать
агрегат Order с помощью порождения событий. В конце пройдемся по положитель
ным и отрицательным аспектам этой методики.
Итак, рассмотрим ограничения традиционного подхода к сохранению данных.
6.1. Разработка бизнес-логики с использованием порождения событий 225
6.1.1. Проблемы традиционного сохранения данных
Традиционный подход к сохранению информации подразумевает привязывание
классов к таблицам баз данных, полей этих классов — к столбцам, а их экземпля
ров — к строкам этих таблиц. На рис. 6.1 показано, как агрегат Order, описанный
в главе 5, накладывается на таблицу ORDER. Его поле OrderLineltems накладывается
на таблицу ORDER_LINE_ITEM.
Рис. 6.1. Традиционный подход к сохранению информации подразумевает привязывание классов
к таблицам, а объектов — к строкам в этих таблицах
Приложение хранит экземпляр заказа в виде строк в таблицах ORDER и ORDER_
LINE_ITEM. Это можно сделать с помощью ORM-фреймворка, такого как JPA, или
с использованием низкоуровневого пакета наподобие MyBATIS.
Очевидно, что этот подход хорошо себя проявляет, так как большинство промыш
ленных приложений хранят данные именно таким образом. Но у него есть несколько
недостатков и ограничений.
□ Объектно-реляционный разрыв.
□ Отсутствие истории агрегатов.
□ Реализация журналирования для аудита требует много усилий и чревата ошибками.
□ Публикация событий является лишь дополнением к бизнес-логике.
Рассмотрим каждую из этих проблем, начиная с объектно-реляционного разрыва.
Объектно-реляционный разрыв
Есть одна старинная проблема под названием «объектно-реляционный разрыв».
Она заключается в фундаментальном, концептуальном несоответствии между
табличной реляционной схемой и графовой структурой развитой доменной мо
дели с ее сложными отношениями. Некоторые аспекты этой проблемы отражены
в непримиримых дискуссиях о целесообразности применения фреймворков для
объектно-реляционного отображения (object/relational mapping, ORM). Например,
226 Глава 6 • Разработка бизнес-логики с порождением событий
Тед Ньюард (Ted Neward) однажды сказал, что «объектно-реляционное отобра
жение — это Вьетнамская война в мире информатики» (blogs.tedneward.com/post/
the-vietnam-of-computer-science/). По правде сказать, я успешно использовал Hibernate
для разработки приложений, в которых схема базы данных была построена на основе
объектной модели. Но эта проблема более глубокая, чем избавление от какого-то
конкретного ORM-фреймворка.
Нехватка истории агрегатов
Еще одно ограничение традиционного подхода к хранению данных состоит в том, что
в нем предусмотрено хранение лишь текущего состояния агрегата. После обновления
агрегата его предыдущее состояние теряется. Если приложению необходимо хранить
историю агрегата (например, если это требуется правовыми нормами), разработчики
сами должны реализовать этот механизм. Это занимает много времени и приводит
к дублированию кода, который должен быть синхронизирован с бизнес-логикой.
Реализация журнала аудита — хлопотный процесс,
чреватый ошибками
Еще одна проблема — журнал аудита. Многие приложения должны вести журнал,
в который записывается информация о том, какие пользователи меняли агрегат.
В некоторых случаях аудит требуется в целях безопасности или соблюдения норма
тивно-правовых норм. Но иногда история действий пользователя является важной
функцией. Например, системы отслеживания проблем и управления задачами, такие
как Asana и JIRA, выводят историю изменений задач и проблем. Трудность реализа
ции аудита связана не только с необходимостью тратить на нее время и усилия, но
и с тем, что бизнес-логика и код ведения журнала могут расходиться, что приводит
к ошибкам.
Публикация событий не является частью бизнес-логики
Еще одно ограничение традиционного подхода к хранению данных состоит в том,
что он обычно не поддерживает публикацию доменных событий. Как обсуждалось
в главе 5, доменными называют события, которые публикуются при изменении
состояния агрегата. В микросервисной архитектуре этот механизм помогает син
хронизировать данные и отправлять уведомления. Некоторые ORM-фреймворки,
такие как Hibernate, могут реагировать на изменение объектов данных с помощью
функций обратного вызова, предоставленных приложением. Но поддержка автома
тической публикации сообщений в рамках транзакции, которая обновляет данные,
отсутствует. Таким образом, как мы уже видели на примере истории и аудита, разра
ботчикам приходится дописывать логику генерации событий, которая потенциально
может утратить синхронизацию с бизнес-логикой. К счастью, у этих проблем есть
решение — порождение событий.
6.1. Разработка бизнес-логики с использованием порождения событий 227
6.1.2. Обзор порождения событий
Порождение событий — это событийный подход к реализации бизнес-логики
и сохранению агрегатов. Агрегат хранится в базе данных в виде цепочки событий.
Каждое событие представляет изменение его состояния. Бизнес-логика агрегата
структурирована вокруг требования о генерации и потреблении этих событий.
Посмотрим, как это работает.
Сохранение агрегатов
с помощью порождения событий
В подразделе 6.1.1 я показал, как традиционные механизмы хранения накладывают
агрегаты на таблицы, их поля — на столбцы, а их экземпляры — на строки. Порож
дение событий основано на концепции доменных событий и работает совсем иначе.
Оно сохраняет каждый агрегат в базе данных, так называемом хранилище событий,
в виде последовательности событий.
Возьмем в качестве примера агрегат Order. Вместо сохранения каждого экзем
пляра Order в виде строки таблицы ORDER порождение событий помещает каждый
агрегат Order в таблицу EVENTS, используя одну или несколько строк (рис. 6.2).
Каждая строка — это доменное событие, такое как Order Created, Order Approved,
Order Shipped и т. д.
Таблица EVENTS
Рис. 6.2. Порождение событий сохраняет каждый агрегат в виде последовательности событий.
Приложения на основе СУРБД могут, к примеру, хранить события в таблице EVENTS
Создавая или обновляя агрегат, приложение вставляет в таблицу EVENTS событие,
которое тот сгенерировал. Приложение загружает агрегат из хранилища, извлекая
228 Глава 6 • Разработка бизнес-логики с порождением событий
и воспроизводя события. Если говорить более подробно, загрузка агрегата состоит
из следующих трех шагов.
1. Загрузка событий агрегата.
2. Создание экземпляра агрегата с помощью конструктора по умолчанию.
3. Перебор событий с вызовом apply ().
Например, в фреймворке Eventuate Client, который мы рассмотрим в подразде
ле 6.2.2, для реконструкции агрегата используется примерно такой код:
Class aggregateclass = ...;
Aggregate aggregate = aggregateclass.newlnstance();
for (Event event : events) {
aggregate = aggregate.applyEvent(event);
}
// использование агрегата...
Он создает экземпляр класса и проходится по событиям, вызывая метод агрегата
applyEvent(). Если вы знакомы с функциональным программированием, то, навер
ное, догадались, что это операция свертывания.
Реконструкция состояния агрегата, хранимого в оперативной памяти, путем
загрузки и воспроизведения событий может показаться странным и непривычным
подходом. Но в некотором смысле это не сильно отличается от того, как ORM-
фреймворки вроде JPА или Hibernate загружают свои сущности. В процессе загрузки
объекта ORM-фреймворк выполняет одну или несколько инструкций SELECT, чтобы
извлечь текущее сохраненное состояние, и создает экземпляр этого объекта, вы
зывая его конструктор по умолчанию. Для инициализации объектов применяется
отражение. Отличительной чертой порождения событий является то, что состояние,
хранимое в оперативной памяти, реконструируется с помощью событий.
Посмотрим, какие ограничения накладывает порождение событий на доменные
события.
События представляют изменения состояния
Согласно определению, данному в главе 5, доменные события — это механизм уведом
ления подписчиков об изменениях, вносимых в агрегаты. События могут содержать
минимальную информацию, такую как ID агрегата, или быть расширены с помощью
данных, которые могут пригодиться типичному потребителю. Например, при соз
дании заказа сервис Order может опубликовать событие OrderCreated. Содержимое
OrderCreated может ограничиваться полем orderld. Или содержать весь заказ, чтобы
его потребителю не нужно было извлекать эти данные из сервиса Order. Факт публи
кации события и его содержимое определяются тем, что именно нужно потребителю.
Но в случае с порождением событий эта ответственность ложится на сам агрегат.
При использовании рассмотренного подхода события генерируются всегда.
Каждое изменение состояния агрегата, включая его создание, представлено домен
ным событием. Каждый раз, когда агрегат меняет свое состояние, он обязан сгене
рировать событие. Например, агрегат Order должен сгенерировать OrderCreated во
время своего создания, а также события вида Order* при каждом своем обновлении.
6.1. Разработка бизнес-логики с использованием порождения событий 229
Это требование куда более жесткое, чем то, что мы видели ранее, когда агрегат гене
рировал только те события, которые были интересны потребителям.
Но это еще не все. Событие должно содержать данные, необходимые агрегату для
перехода к новому состоянию. Состояние агрегата включает в себя значения полей
его объекта. Изменение состояния может заключаться в простом изменении поля
объекта, такого как Order.state. Оно также может подразумевать добавление или
удаление других объектов, таких как позиции заказа.
Представьте, что S — текущее состояние агрегата, а его новое состояние обозначе
но S' (рис. 6.3). Событие Е, представляющее изменение состояния, должно содержать
такие данные, чтобы после вызова order.apply(E) заказ перешел из состояния S
в состояние S ’. В следующем разделе вы увидите, что apply () — это метод, который
изменяет состояние, представленное событием.
Рис. 6.3. Применение события Е, когда заказ находится в состоянии S, должно изменить
состояние на S'. Событие должно содержать данные, необходимые для выполнения перехода
между состояниями
Некоторые события, такие как Order Shipped, содержат немного данных (или
не содержат никаких) и просто описывают переход состояния. Метод apply() об
рабатывает событие Order Shipped, меняя поле заказа status на SHIPPED. Но есть
события, которые несут в себе много информации. Например, событие OrderCreated
должно содержать все данные, необходимые методу apply () для инициализации за
каза, включая его позиции, сведения о платеже, доставке и т. д. Поскольку событие
OrderCreated используется для хранения агрегата, оно больше не может ограничи
ваться одним полем orderld.
Методы агрегата полностью полагаются на события
Чтобы обработать запрос и обновить агрегат, бизнес-логика вызывает командный
метод из его корня. В традиционных приложениях командные методы обычно
проверяют свои аргументы и затем обновляют одно или несколько полей агрегата.
В приложениях, основанных на порождении событий, командные методы должны
генерировать события. Результатом вызова командного метода агрегата является
последовательность событий, описывающая изменения, которые нужно внести
230 Глава 6 • Разработка бизнес-логики с порождением событий
в состояние (рис. 6.4). Эти события хранятся в базе данных и применяются к агре
гату для его обновления.
Рис. 6.4. Обработка команды генерирует события без изменения состояния агрегата. Агрегат
обновляется путем применения событий
Обязательные генерация и применение событий требуют хоть и прямолинейной,
но реструктуризации бизнес-логики. Порождение событий превращает командный
метод в два и более метода. Первый из них принимает командный объект, который
представляет запрос, и определяет, какое изменение состояния нужно выполнить.
Он проверяет свои аргументы и, не меняя состояния агрегата, возвращает список
событий, описывающих переход состояния. Если команду не удается выполнить,
этот метод обычно генерирует исключение.
Остальные методы принимают в качестве параметра событие определенного
типа и обновляют агрегат. Для каждого события предусмотрен свой метод. Важно
отметить, что ни один из этих методов не может отказать, поскольку событие пред
ставляет собой изменение состояния, которое уже произошло. В каждом случае
агрегат обновляется на основе события.
В событийном фреймворке Eventuate Client, который мы подробнее рассмотрим
в подразделе 6.2.2, эти методы называются process() и apply(). Первый принимает
в качестве параметра командный объект, который содержит запрос на обновление,
и возвращает список событий. Второй принимает событие и возвращает пустое
значение. Агрегат определяет несколько перегруженных версий этих методов: по
одной разновидности process() для каждого класса команд и по одному методу
apply () для каждого типа событий, который генерируется агрегатом. Пример по
казан на рис. 6.5.
В этом примере метод reviseOrder() был заменен методами process() и apply(). pro
cess () принимает в качестве параметра команду ReviseOrder, ее класс определяется
введением объекта-параметра (refactoring.com/catalog/introduceParameterObject.html)
в метод reviseOrder(). Итогом работы метода process() является либо возвращение
события OrderRevisionProposed, либо генерация исключения, если уже слишком
поздно пересматривать заказ или предлагаемый пересмотр конфликтует с мини
мальной суммой заказа. Метод apply () для события OrderRevisionProposed меняет
состояние заказа на REVISION PENDING.
6.1. Разработка бизнес-логики с использованием порождения событий 231
Р
и
с.
6
.5
. П
ор
ож
де
ни
е
со
бы
ти
й
ра
зд
ел
яе
т
м
ет
од
д
ля
о
бн
ов
ле
ни
я
аг
ре
га
та
н
а
дв
а
др
уг
их
м
ет
од
а:
p
ro
ce
ss
()
п
ри
ни
м
ае
т
ко
м
ан
ду
232 Глава 6 • Разработка бизнес-логики с порождением событий
Агрегат создается с помощью следующих шагов.
1. Создание экземпляра корня агрегата с помощью его конструктора по умолча
нию.
2. Вызов process() для генерации новых событий.
3. Обновление агрегата путем перебора новых событий и вызова его метода apply ().
4. Сохранение новых событий в хранилище событий.
Обновление агрегата состоит из таких этапов.
1. Загрузка событий агрегата из хранилища событий.
2. Создание экземпляра корня агрегата с помощью его конструктора по умолчанию.
3. Перебор загруженных событий и вызов apply () из корня агрегата.
4. Вызов его метода process() для генерации новых событий.
5. Обновление агрегата путем перебора новых событий и вызова apply ().
6. Сохранение новых событий в хранилище событий.
Чтобы увидеть, как это работает, рассмотрим разновидность агрегата Order,
основанную на порождении событий.
Агрегат Order на основе порождения событий
В листинге 6.1 показаны поля и методы агрегата Order, отвечающие за его созда
ние. Версия этого агрегата, основанная на порождении событий, чем-то похожа на
разновидность Order из главы 5 с использованием JPA. Их поля почти идентичны,
и события, которые они генерируют, имеют много общего. Разница состоит в том,
что в новой версии бизнес-логика сосредоточена на обработке команд, которые
генерируют события, и применении этих событий для обновления своего состоя
ния. Все методы, которые создают или обновляют агрегат на основе JPA, такие как
createOrder() и reviseOrder(), заменены в новой версии на process() и арр1у().
Листинг 6.1. Поля агрегата Order и его методы, которые инициализируют экземпляр
public class Order {
private Orderstate state;
private Long consumerld;
private Long restaurantld;
private OrderLineltems orderLineltems;
private Deliveryinformation deliveryInformation;
private Paymentinformation paymentinformation;
private Money orderMinimum;
Проверяет команду
public Order () { и возвращает OrderCreatedEvent
}
public List<Event> process(CreateOrderCommand command) { <
... validate command ...
6.1. Разработка бизнес-логики с использованием порождения событий 233
return events(new OrderCreatedEvent(command.getOrderDetails()));
}
public void apply(OrderCreatedEvent event) { ◄-------------------
OrderDetails orderDetails = event.getOrderDetails();
this.orderLineltems = new OrderLineItems(orderDetails.getLineItems());
this.orderMinimum = orderDetails.getOrderMinimum();
this.state = APPROVAL-PENDING;
j. Применяет OrderCreatedEvent,
инициализируя поля Order
Поля этого класса похожи на те, которые мы видели в агрегате Order, основанном
на JPA. Единственное отличие в том, что идентификатор агрегата хранится за его
пределами. В то же время методы выглядят совсем иначе. Вместо фабричного ме
тода createOrder() мы видим две операции, process() и apply(). Метод process()
принимает команду CreateOrder и генерирует событие OrderCreated. Метод apply ()
принимает событие OrderCreated и инициализирует поля класса Order.
Теперь рассмотрим чуть более сложную бизнес-логику для пересмотра за
каза. Ранее этот код состоял из трех методов: reviseOrder(), confirmRevision()
и rejectRevision(). В версии, основанной на порождении событий, они заменены
тремя методами process() и несколькими методами apply(). То, как reviseOrder()
и confirmRevision() выглядят после рефакторинга, показано в листинге 6.2.
Листинг 6.2. Методы process() и apply() для пересмотра агрегата Order
public class Order {
public List<Event> process(Reviseorder command) { ◄-------
OrderRevision orderRevision = command.getOrderRevision();
switch (state) {
case APPROVED:
Проверяем, можно ли
пересмотреть заказ
и достигает ли пересмотренная
версия минимальной суммы
LineltemQuantityChange change =
orderLineltems.lineltemQuantityChange(orderRevision);
if (change.newOrderTotal.isGreaterThanOrEqual(orderMinimum)) {
throw new OrderMinimumNotMetException();
}
return singletonList(new OrderRevisionProposed(orderRevision>
change.currentOrderTotal, change.newOrderTotal));
default:
throw new UnsupportedStateTransitionException(state);
}
Меняем состояние заказа
public void apply(OrderRevisionProposed event) { <
this.state = REVISION-PENDING;
}
на REVISION_PEND!NG
public List<Event> process(ConfirmReviseOrder command) { <
OrderRevision orderRevision = command.getOrderRevision();
switch (state) {
case REVISION-PENDING:
LineltemQuantityChange lied =
Проверяем, можно ли
подтвердить изменения,
и возвращаем
событие OrderRevised
234 Глава 6 • Разработка бизнес-логики с порождением событий
orderLineltems.lineltemQuantityChange(orderRevision);
return singletonList(new OrderRevised(orderRevision,
lied.currentOrderTotal, lied.newOrderTotal));
default:
throw new UnsupportedStateTransitionException(state);
}
}
j Пересматриваем заказ
public void apply(OrderRevised event) { ◄—
OrderRevision orderRevision ■ event.getOrderRevision();
if (!orderRevision.getRevisedLineltemQuantities().isEmptyO) {
orderLineltems.updateLineltems(orderRevision);
}
this.state = APPROVED;
}
Как видите, каждый метод был заменен операцией process() и одной или несколь
кими операциями apply(). Вместо reviseOrder() мы получили process(ReviseOrder)
и apply (OrderRevisionProposed). Аналогично метод confirmRevision() был заменен
на process(ConfirmReviseOrder)и apply(OrderRevised).
6.1.3. Обработка конкурентных обновлений с помощью
оптимистичного блокирования
Ситуация, когда два запроса или больше одновременно обновляют один агрегат,
не такая уж и редкость. Приложения, которые используют традиционную модель
хранения данных, часто применяют оптимистичное блокирование, чтобы транзакции
не перезаписывали изменения друг друга. Чтобы определить, изменился ли агрегат
с тех пор, как он был прочитан, оптимистичное блокирование задействует столбец
с версией. Приложение накладывает корень агрегата на таблицу со столбцом VERSION,
который инкрементируется при каждом обновлении записи. Приложение обновляет
агрегат с помощью выражения UPDATE, например:
UPDATE AGGREGATE-ROOT-TABLE
SET VERSION = VERSION + 1 ...
WHERE VERSION = <исходная версия>
Выражение UPDATE будет успешным, только если версия не изменилась с момента,
когда приложение в последний раз считывало агрегат. Если агрегат считывается
двумя транзакциями, успешно завершится только та, которая первой выполнит
обновление. Вторая будет отменена, поскольку номер версии изменился, благодаря
этому она не перезапишет изменения, внесенные первой транзакцией.
Хранилище событий тоже может использовать оптимистичное блокирование
для обработки конкурентных обновлений. Каждый экземпляр агрегата содержит
версию, которая считывается вместе с событиями. Когда приложение вставляет со
бытие, хранилище проверяет, не изменилась ли его версия. В качестве номера версии
можно взять количество событий. По, как будет показано в разделе 6.2, хранилище
событий может явно назначать номера версий.
6.1. Разработка бизнес-логики с использованием порождения событий 235
6.1.4. Порождение и публикация событий
Строго говоря, шаблон «Порождение событий» сохраняет агрегат в виде событий,
из которых затем восстанавливает его текущее состояние. Этот подход можно
использовать также в качестве надежного механизма для публикации событий.
Сохранение события в хранилище по своей сути атомарная операция. Нам нужно
реализовать механизм для доставки всех сохраненных событий заинтересованным
потребителям.
В главе 3 были описаны механизмы для публикации сообщений, которые встав
ляются в базу данных в рамках транзакции: опрашивание и отслеживание транз
акционного журнала. Приложение, основанное на порождении событий, может
публиковать события одним из этих способов. Основное отличие в том, что собы
тия хранятся в таблице EVENTS постоянно, а не временно, как в случае с таблицей
OUTBOX, из которой они затем удаляются. Давайте рассмотрим эти подходы, начав
с опрашивания.
Публикация событий с помощью опрашивания
Если для хранения событий используется таблица EVENTS (рис. 6.6), издатель может
ее опрашивать на предмет новых записей, выполняя выражение SELECT и публикуя
результат для брокера сообщений. Самое сложное — определить, какие из собы
тий новые. Представьте, к примеру, что значения полей eventld увеличиваются
монотонно. На первый взгляд было бы логично позволить издателю записывать
последнее значение eventld, которое он обработал. После этого он смог бы извлечь
новые события с помощью примерно такого запроса: SELECT * FROM EVENTS where
event_id > ? ORDER BY event_id ASC.
Проблема этого подхода в том, что порядок фиксации транзакций может не со
впасть с порядком, в котором они генерируют события. В итоге одно из событий
может быть случайно пропущено издателем. Этот сценарий показан на рис. 6.6.
В этом сценарии транзакция А вставляет событие, у которого столбец EVENT_ID
равен 1010. Затем транзакция В вставляет событие со столбцом EVENT_ID, рав
ным 1020, и фиксируется. Теперь, если издатель выполнит запрос к таблице EVENTS,
он найдет событие 1020. Позже, когда зафиксируется транзакция А, станет доступ
ным событие 1010, но издатель его проигнорирует.
Одно из решений этой проблемы состоит в создании в таблице EVENTS дополни
тельного столбца, который отслеживает публикацию событий. В результате издатель
использовал бы следующий процесс.
1. Найти неопубликованные события с помощью выражения SELECT: SELECT * FROM
EVENTS where PUBLISHED = 0 ORDER BY event_id ASC.
2. Опубликовать события для брокера сообщений.
3. Пометить события такими, которые были опубликованы: UPDATE EVENTS SET
PUBLISHED = 1 WHERE EVENT-ID in.
Благодаря этому подходу издатель не будет пропускать события.
236 Глава 6 • Разработка бизнес-логики с порождением событий
Фиксируется
последней
Транзакция А Транзакция В
BEGIN BEGIN
INSERT event with
EVENTJD =1010
INSERT event with
EVENTJD = 1020
COMMIT
SELECT * FROM EVENTS Ы
WHERE EVENTJD >....
COMMIT
SELECT * FROM EVENTS '
WHERE EVENTJD > 1020...
Извлекает
событие 1020
Пропускает событие,
потому что 1010 £1020
Рис. 6.6. Сценарий, в котором событие пропускается из-за того, что его транзакция А фиксируется
после транзакции В. Опрашивающий запрос видит event!d=1020 и позже пропускает eventId=1010
Надежная публикация событий с помощью отслеживания
транзакционного журнала
Более развитые хранилища событий используют отслеживание транзакционного
журнала, описанное в главе 3. Эта методика гарантирует публикацию событий
и является более производительной и масштабируемой. Например, она применяется
в открытом хранилище событий Eventuate Local — оно считывает события, вставлен
ные в таблицу EVENTS из транзакционного журнала базы данных, и публикует их для
брокера сообщений. Более подробно принцип работы Eventuate Local обсуждается
в разделе 6.2.
6.1.5. Улучшение производительности
с помощью снимков
Агрегат Order выполняет лишь несколько переходов между состояниями, поэтому
генерирует небольшое количество событий. Обращение к хранилищу событий с по
следующей реконструкцией агрегата Order имеет высокую эффективность. Но у бо
лее долговечных агрегатов может быть намного больше событий. Примером такого
агрегата служит Account. Со временем загрузка и сворачивание его событий могут
существенно замедлиться.
Эту проблему часто решают периодическим сохранением снимков состояния
агрегата. Пример использования снимка показан на рис. 6.7. Приложение восста
навливает состояние агрегата, загружая его последний снимок и только те события,
которые произошли с момента его создания.
6.1. Разработка бизнес-логики с использованием порождения событий 237
Рис. 6.7. Применение снимков улучшает производительность, так как вам больше не нужно
загружать все события. Приложению достаточно извлечь снимок и события, произошедшие
после его создания
В этом примере версия снимка имеет номер N. Чтобы восстановить состояние
агрегата, приложению нужно загрузить только сам снимок и два события, которые
за ним последовали. Предыдущие N событий не загружаются из хранилища.
При восстановлении агрегата из снимка приложение сначала использует снимок
для создания экземпляра агрегата, а затем последовательно применяет события.
Например, фреймворк Eventuate Client, описанный в разделе 6.2, восстанавливает
агрегат с помощью примерно такого кода:
Class aggregateclass = ...;
Snapshot snapshot = ...;
Aggregate aggregate = recreateFromSnapshot(aggregateClass, snapshot);
for (Event event : events) {
aggregate = aggregate.applyEvent(event);
}
// использование агрегата...
Экземпляр агрегата восстанавливается из снимка, а не с помощью конструктора
по умолчанию. Если у агрегата простая, легко сериализуемая структура, в каче
стве снимка может использоваться представление в формате JSON. Снимки более
сложных агрегатов создаются с помощью шаблона «Хранитель» (ru.wikipedia.org/wiki/
Хранитель_(шаблон_проектирования)).
У агрегата Customer, показанного в примере онлайн-хранилища, очень простая
структура: он содержит информацию о заказчике, его кредитном лимите и пр. Сни
мок представляет собой его состояние, переведенное в формат JSON. На рис. 6.8
показано, как воссоздать агрегат Customer из снимка, основанного на его состоянии
на момент события 103. Сервису Customer нужно загрузить снимок и события, про
изошедшие после 103-го.
Сервис Customer воссоздает агрегат Customer путем десериализации снимка в фор
мате JSON с последующей загрузкой и применением событий с 104-го по 106-е.
238 Глава 6 • Разработка бизнес-логики с порождением событий
SNAPSHOTSEVENTS
entity_id event_type event_type entityjd event_data entityjd event_type entityjd snapshot-data
103 Customer 101 {...} 103 Customer 101 {name:,...}
104 Credit
Reserved Customer 101 {...}
105 Address
Changed Customer 101
106 Credit
Reserved Customer 101
Рис. 6.8. Сервис Customer воссоздает агрегат Customer путем десериализации снимка в формате
JSON с последующей загрузкой и применением событий с 104-го по 106-е
6.1.6. Идемпотентная обработка сообщений
Сервисы часто потребляют сообщения из других приложений или сервисов. Напри
мер, они могут потреблять доменные события, публикуемые агрегатом, или команд
ные сообщения, отправляемые оркестратором повествования. Как описывалось
в главе 3, при разработке потребителя сообщений важно убедиться в его идемпотент
ности, поскольку брокер может доставить одно и то же сообщение несколько раз.
Потребитель является идемпотентным, если его можно безопасно вызывать по
нескольку раз с одним и тем же сообщением. Например, фреймворк Eventuate Tram
реализует идемпотентность, обнаруживая и отклоняя повторяющиеся сообщения.
Он записывает идентификаторы обработанных сообщений в таблицу PROCESSED-
MESSAGES в рамках локальной ACID-транзакции, с помощью которой бизнес-ло
гика создает и обновляет агрегаты. Если ID сообщения уже находится в таблице
PROCESSED_MESSAGES, это означает, что мы имеем дело с дубликатом, который можно
отклонить. Бизнес-логика, основанная на порождении событий, должна реализовать
аналогичный механизм. Как это сделать, зависит от того, реляционную СУРБД или
NoSQL использует хранилище событий.
Идемпотентная обработка сообщений с хранилищем событий
на основе СУРБД
Если приложение задействует хранилище событий на основе СУРБД, оно может
применить идентичный подход к обнаружению и отклонению дубликатов. ID со
общения вставляется в таблицу PROCESSED_MESSAGES в рамках транзакции, которая
добавляет события в таблицу EVENTS.
Идемпотентная обработка сообщений с хранилищем событий
на основе NoSQL
Хранилище событий на основе NoSQL с ограниченной транзакционной моделью
должно использовать иной механизм для реализации идемпотентной обработки
сообщений. Потребитель должен каким-то образом автоматически сохранять собы-
6.1. Разработка бизнес-логики с использованием порождения событий 239
тия и записывать ID сообщения. К счастью, для этого существует простое решение.
На время обработки сообщения потребитель хранить его идентификатор внутри ге
нерируемых событий. Для обнаружения дубликатов он следит за тем, чтобы ни одно
из событий агрегата не содержало ID сообщения.
Одна из трудностей данного подхода связана с тем, что обработка сообщения
может не сгенерировать никаких событий. Это означает следующее: у нас может
не быть записи о том, что сообщение было обработано. Повторная доставка и обра
ботка того же сообщения может привести к некорректному поведению. Рассмотрим,
к примеру, такой сценарий.
1. Сообщение А было обработано, но не обновило агрегат.
2. Сообщение В было обработано, а его потребитель обновил агрегат.
3. Сообщение А доставляется повторно, но ввиду отсутствия записи о его обработке
потребитель обновляет агрегат.
4. Сообщение В обрабатывается еще раз...
В этом сценарии повторная доставка событий приводит к разным и, возможно,
некорректным результатам.
Чтобы этого избежать, можно сделать так, чтобы событие публиковалось всегда.
Если агрегат ничего не генерирует, приложение сохраняет псевдособытие лишь
для того, чтобы записать ID сообщения. Такие псевдособытия потребитель должен
игнорировать.
6.1.7. Развитие доменных событий
Шаблон «Порождение событий», по крайней мере на концептуальном уровне, со
храняет события навсегда. Это палка о двух концах. С одной стороны, приложение
получает журнал аудита с записями об изменениях, точность которых гарантируется.
Это также позволяет приложению воссоздать любое предыдущее состояние агрегата.
Но с другой — может возникнуть проблема, поскольку структура многих событий
со временем меняется.
Существует вероятность того, что приложению придется иметь дело с несколь
кими версиями событий. Например, у сервиса, загружающего агрегат Order, может
возникнуть необходимость в сохранении разных версий событий. Точно так же по
требитель потенциально может видеть несколько версий.
Давайте сначала посмотрим, каким образом события могут меняться, а затем
я опишу распространенный подход к обработке этих изменений.
Развитие структуры события
На концептуальном уровне приложение, основанное на порождении событий, имеет
трехуровневую структуру.
□ Состоит из одного или нескольких агрегатов.
□ Определяет события, генерируемые каждым агрегатом.
□ Определяет структуру событий.
240 Глава 6 • Разработка бизнес-логики с порождением событий
В табл. 6.1 собраны разные типы изменений, которые могут возникнуть на каж
дом уровне.
Таблица 6.1. Разные способы развития событий приложения
Уровень Изменение Обратно совместимое
Структура Определение нового типа агрегата Да
Удаление агрегата Удаление существующего агрегата Нет
Переименование агрегата Изменение названия типа агрегата Нет
Агрегат Добавление нового типа событий Да
Удаление события Удаление типа событий Нет
Переименование события Изменение названия типа событий Нет
Событие Добавление нового поля Да
Удаление поля Удаление поля Нет
Переименование поля Переименование поля Нет
Изменение типа поля Изменение типа поля Нет
Эти изменения возникают естественным образом по мере развития доменной
модели сервиса, например, когда меняются требования к сервису или его разработ
чики начинают лучше понимать проблемную область и улучшают доменную модель.
На структурном уровне разработчики добавляют, удаляют и переименовывают клас
сы агрегатов. На уровне отдельного агрегата могут измениться типы генерируемых
событий. Разработчики могут изменить структуру типа события, добавив, удалив
или изменив название либо тип поля.
К счастью, многие из этих изменений обратно совместимы. Например, добавление
поля к событию вряд ли повлияет на потребителя — он просто проигнорирует неиз
вестное поле. Другие изменения не обладают обратной совместимостью. Например,
изменение имени события или его поля требует обновления потребителя событий
этого типа.
Управление структурными изменениями путем приведения
к базовому типу
В мире SQL с изменениями структуры базы данных обычно справляются за счет ми
грации. Каждое структурное изменение представлено миграцией — SQL-скриптом,
который меняет схему и переносит на нее имеющиеся данные. Миграции хранятся
в системе управления версиями и применяются к базе данных с помощью таких
инструментов, как Flyway.
Приложение, основанное на порождении событий, может использовать анало
гичный подход для работы с обратно несовместимыми изменениями. Но вместо
приведения событий к новой структуре соответствующий фреймворк преобразует их
в момент загрузки из хранилища. Обновлением отдельных событий до новой версии
6.1. Разработка бизнес-логики с использованием порождения событий 241
занимается компонент, который часто называют upcaster. В итоге код приложения
всегда имеет дело с актуальной структурой событий.
Итак, мы обсудили принцип работы порождения событий. Теперь рассмотрим
его преимущества и недостатки.
6.1.8. Преимущества порождения событий
Порождение событий имеет как положительные, так и отрицательные стороны.
К положительным можно отнести:
□ надежную публикацию доменных событий;
□ сохранение истории изменений агрегата;
□ отсутствие большинства проблем, связанных с объектно-реляционным раз
рывом;
□ машину времени для разработчиков.
Рассмотрим каждое из этих преимуществ более подробно.
Надежная публикация доменных событий
Основное преимущество порождения событий — надежная публикация уведомле
ний о каждом изменении состояния агрегата. Это хорошая основа для событийной
микросервисной архитектуры. К тому же каждое событие может хранить идентифи
катор пользователя, который внес изменение, что позволяет вести гарантированно
корректный журнал аудита. Поток событий можно использовать для целого ряда
других задач, включая уведомление пользователей, интеграцию приложений, ана
литику и мониторинг.
Сохранение истории изменений агрегата
Еще одно преимущество порождения событий состоит в сохранении всей истории
изменений каждого агрегата. Вы можете с легкостью реализовать временные за
просы, которые извлекают агрегат в одном из его предыдущих состояний. Чтобы
определить состояние агрегата в заданный момент времени, нужно свернуть собы
тия, которые произошли до этого момента. Например, вы можете легко рассчитать
доступные кредитные средства клиента в какой-то момент в прошлом.
Отсутствие большинства проблем, связанных
с объектно-реляционным разрывом
Порождение событий основывается скорее на их постоянном хранении, чем на агре
гации. События обычно имеют простую структуру, которую легко сериализовать.
Как уже упоминалось, сервис может сделать снимок сложного агрегата, сериали
зовав его текущее состояние. Это вводит новый уровень опосредованности между
агрегатом и его сериализованным представлением.
242 Глава 6 • Разработка бизнес-логики с порождением событий
Машина времени для разработчиков
Шаблон «Порождение событий» хранит историю всего, что происходило на протя
жении жизненного цикла приложения. Представьте, что разработчикам FTGO нуж
но реализовать новое требование к заказчикам, которые добавляют товар в корзину
покупок и затем удаляют его оттуда. Традиционное приложение не сохранило бы
этой информации, поэтому таким пользователям можно было бы показывать рекла
му только после реализации соответствующей возможности. Для сравнения: при
ложение на основе порождения событий может сразу же начать показывать рекламу
пользователям, которые производили такие действия в прошлом. Разработчики
получают своего рода машину времени, с помощью которой могут перемещаться
в прошлое и реализовывать непредвиденные требования.
6.1.9. Недостатки порождения событий
Порождение событий не панацея. Вот его недостатки.
□ Оно имеет другую модель программирования с высоким порогом вхождения.
□ Оно так же сложно, как приложение, основанное на обмене сообщениями.
□ Меняющиеся события могут создать проблемы.
□ Усложняется удаление данных.
□ Обращение к хранилищу событий связано с определенными трудностями.
Рассмотрим каждый из этих пунктов.
Другая модель программирования
с высоким порогом вхождения
Эта модель программирования из-за своей необычности имеет высокий порог вхож
дения. Чтобы интегрировать порождение событий в существующее приложение,
вы должны переписать бизнес-логику. К счастью, это довольно прямолинейное
преобразование, которое можно выполнить во время миграции приложения на
микросервисы.
Сложность приложения, основанного
на обмене сообщениями
Еще одним недостатком порождения событий является то, что брокер сообщений
обычно гарантирует доставку не менее одного раза. Если обработчики событий не
идемпотентные, они должны обнаруживать и отклонять дубликаты. В этом способны
помочь фреймворки, которые назначают каждому событию монотонно растущий
идентификатор. Обработчик событий может обнаружить дубликат, отслеживая
самое большое значение ID, которое ему встречалось. Это может происходить ав
томатически, когда обработчики событий обновляют агрегаты.
6.1. Разработка бизнес-логики с использованием порождения событий 243
Меняющиеся события могут создать проблемы
При использовании порождения событий структура событий (и снимков!) будет
меняться со временем. Поскольку события хранятся вечно, агрегатам, возможно,
придется сворачивать их с учетом нескольких версий структуры. Существует реальный
риск того, что агрегаты станут слишком раздутыми из-за кода, предназначенного для
разных версий. Как упоминалось в подразделе 6.1.7, хорошим решением этой проблемы
будет обновление событий до последней версии во время их загрузки из хранилища.
Это позволяет хранить код обновления событий отдельно, что упрощает агрегаты,
поскольку им нужно применять лишь самую последнюю версию событий.
Усложняется удаление данных
Одна из целей порождения событий заключается в сохранении истории агрегатов,
поэтому данные намеренно хранятся вечно. При использовании этого шаблона тра
диционно применяется мягкое удаление. Приложение удаляет агрегат, устанавливая
ему флаг удален, а тот, как правило, генерирует событие Deleted, уведомляющее всех
заинтересованных потребителей. Любой код, который обращается к агрегату, может
проверить нужный флаг и действовать соответственно.
Мягкое удаление подходит для многих видов данных. Но одна из трудностей за
ключается в соблюдении общего регламента защиты данных (General Data Protection
Regulation, GDPR). Это европейская правовая норма по защите информации и кон
фиденциальности, которая дает человеку право стереть свое присутствие в Интер
нете (gdpr-info.eu/art-17-gdpr/). Приложение должно быть способно «забыть» о личной
информации пользователя, такой как адрес его электронной почты. Проблема со
стоит в том, что в приложениях, основанных на порождении событий, электронный
адрес может храниться в событии AccountCreated или использоваться в качестве
первичного ключа для агрегата. Приложение должно каким-то образом забыть
о пользователе, не удаляя события.
Один из механизмов решения этой проблемы — шифрование. Каждый пользова
тель имеет ключ шифрования, который хранится в отдельной таблице базы данных.
Прежде чем попасть в хранилище, все события, содержащие личную информацию
о пользователе, шифруются с помощью этого ключа. Когда пользователь запра
шивает удаление всех своих данных, приложение удаляет из базы данных запись
с ключом. Таким образом, личные данные пользователя, в сущности, удалены, так
как события больше нельзя расшифровать.
Шифрование событий решает большинство проблем, связанных с удалением
личных данных. Но если какой-то аспект личной информации пользователя задей
ствован в идентификаторе агрегата, удаления одного лишь ключа может оказаться
недостаточно. Например, в разделе 6.2 описывается хранилище событий с таблицей
entities, первичным ключом которой является ID агрегата. Одно из решений — ме
тодика псевдоанонимизации — замена электронного адреса на токен UUID и исполь
зование его в качестве ID агрегата. Приложение хранит в базе данных связь между
токеном UUID и электронным адресом. Когда пользователь запрашивает удаление
своих данных, приложение удаляет из таблицы запись об электронной почте, в ре
зультате чего связь между ней и токеном UUID безвозвратно теряется.
244 Глава 6 • Разработка бизнес-логики с порождением событий
Трудности при обращении к хранилищу событий
Представьте, что вам нужно найти клиентов, которые исчерпали свой кредитный
лимит. Поскольку у вас нет столбца с кредитными средствами, вы не можете напи
сать SELECT * FROM CUSTOMER WHERE CREDIT_LIMIT = 0. Вместо этого нужно использовать
сложный и потенциально неэффективный запрос с вложенными выражениями
SELECT — кредитный лимит вычисляется сворачиванием событий, установивших
изначальные кредитные средства, с последующей их подгонкой. Что еще хуже,
хранилища событий на основе NoSQL обычно поддерживают только запросы по
первичному ключу. Следовательно, вам необходимо реализовать запросы с помощью
методики CQRS, описанной в главе 7.
6.2. Реализация хранилища событий
Приложение, использующее порождение событий, хранит события в отдельном
хранилище. Хранилище событий — это гибрид базы данных и брокера сообщений.
Оно ведет себя как БД, потому что у него есть API для вставки и извлечения событий
агрегата по первичному ключу. Но оно похоже и на брокер сообщений, потому что
у него есть API, который позволяет подписываться на события.
Существует несколько разных способов реализации хранилища событий. Вы мо
жете написать с нуля все, в том числе собственный фреймворк для порождения со
бытий. Хранить записи можно, к примеру, в СУРБД. Простой, хотя и низкопроизво
дительный способ публикации событий состоит в том, что подписчики опрашивают
таблицу EVENTS на предмет новых событий. Но, как упоминалось в подразделе 6.1.4,
одна из трудностей этого подхода связана с необходимостью гарантировать, что
подписчик будет обрабатывать все события в правильном порядке.
Еще один вариант заключается в использовании специализированного хранили
ща событий, которое обычно предоставляет богатый набор возможностей, а также
улучшенные производительность и масштабируемость. Вы можете выбрать один из
следующих проектов.
□ Event Store — хранилище событий с открытым исходным кодом на основе .NET
от Грега Янга (Greg Young), пионера в области порождения событий (https://
eventstore.org/).
□ Lagom — микросервисный фреймворк от компании Lightbend, ранее известной
как Typesafe (www.lightbend.com/lagom-framework).
□ Axon — фреймворк с открытым исходным кодом на языке Java для разработки
событийных приложений, которые используют порождение событий и CQRS
(www.axonframework.org).
□ Eventuate — фреймворк, разработанный моим стартапом Eventuate (eventuate.io).
Он имеет две версии: Eventuate SaaS — облачный сервис и Eventuate Local — от
крытый проект на основе Apache Kafka/СУРБД.
Все эти фреймворки имеют свои особенности, но основная концепция у них общая.
Поскольку лучше всего я знаком с проектом Eventuate, именно он и будет рассматри-
6.2. Реализация хранилища событий 245
ваться в этой книге. Он имеет прямолинейную, простую для понимания архитектуру,
которая иллюстрирует концепции порождения событий. Вы можете использовать его
в своих приложениях, заново реализовать его принципы или выбрать один из анало
гичных фреймворков, применив знания, которые здесь приобретете.
Я начну с описания работы хранилища событий Eventuate Local. Затем мы рас
смотрим фреймворк Eventuate Client для Java, с помощью которого легко написать
бизнес-логику, основанную на порождении событий, и хранилище Eventuate Local.
6.2.1. Принцип работы хранилища событий
Eventuate Local
Eventuate Local — это хранилище событий с открытым исходным кодом. Его архи
тектура показана на рис. 6.9. События хранятся в базе данных, такой как MySQL.
Приложение вставляет и извлекает события агрегатов по первичному ключу. Сами
события отправляются брокером сообщений, таким как Apache Kafka. События из
базы данных попадают к брокеру сообщений с помощью механизма отслеживания
журнала транзакций.
Рис. 6.9. Архитектура Eventuate Local. Она состоит из базы данных (такой как MySQL)
для хранения событий, брокера (такого как Apache Kafka), доставляющего события к подписчикам,
и ретранслятора, который публикует события, хранящиеся в БД, для брокера
246 Глава 6 • Разработка бизнес-логики с порождением событий
Рассмотрим разные компоненты Eventuate Local, начиная со структуры базы
данных.
Структура базы данных событий Eventuate Local
База данных событий состоит из трех таблиц.
□ events — хранит события.
□ entities — по одной строке для каждой сущности.
□ snapshots — хранит снимки.
Главная таблица — events. Ее структура очень похожа на ту, что показана на
рис. 6.2. Вот ее определение:
create table events (
event_id varchar(1000) PRIMARY KEY,
event-type varchar(1000),
event_data varchar(1000) NOT NULL,
entity_type VARCHAR(1000) NOT NULL,
entity_id VARCHAR(1000) NOT NULL,
triggering_event VARCHAR(1000)
);
Столбец triggering_event используется для обнаружения повторяющихся со-
бытий/сообщений. Он хранит ID сообщения/события, обработка которого сгене
рировала это событие.
В таблице entities хранится текущая версия каждой сущности. Она применяется
для реализации оптимистичного блокирования. Ее определение:
create table entities (
Mentity_type VARCHAR(1000),
entity_id VARCHAR(1000),
entity_version VARCHAR(1000) NOT NULL,
PRIMARY KEY(entity_type, entity_id)
);
Во время сохранения сущности в эту таблицу вставляется новая запись. При каж
дом изменении сущности обновляется столбец entity_version.
Таблица snapshots хранит снимки каждой сущности. Ее определение выглядит
так:
create table snapshots (
entity-type VARCHAR(1000),
entity_id VARCHAR(1000),
entity_version VARCHAR(1000),
snapshot-type VARCHAR(1000) NOT NULL,
snapshot_json VARCHAR(1000) NOT NULL,
triggering_events VARCHAR(1000),
PRIMARY KEY(entity_type, entity_id, entity_version)
)
6.2. Реализация хранилища событий 247
Столбцы entity_type и entity_id определяют сущность снимка. В столбце
snapshot_json находится сериализованное представление снимка, а в snapshot
type — его тип. В entity_version указывается версия сущности, с которой был
сделан снимок.
Эта схема поддерживает три операции: -Find(), create() и updateQ. Операция
find() обращается к таблице snapshots, чтобы извлечь последний снимок, если
таковой имеется. При наличии снимка find() ищет в таблице events все события,
у которых столбец event_id больше, чем entity_version снимка. В противном случае
f ind() извлекает все события для заданной сущности. Эта операция также обраща
ется к таблице entity, чтобы получить текущую версию сущности.
Операция create() вставляет запись в таблицу entity и события в таблицу
events. Операция updateQ вставляет события в таблицу events. Она также прове
ряет оптимистичное блокирование, обновляя версию сущности в таблице entities
с помощью выражения UPDATE:
UPDATE entities SET entity_version = ?
WHERE entity_type = ? and entity_id = ? and entity_version = ?
Это выражение проверяет, не изменилась ли версия сущности с момента ее по
следнего извлечения операцией find(). Оно также обновляет entity_version до
новой версии. Операция update() выполняет эти обновления в рамках транзакции,
чтобы обеспечить атомарность.
Теперь вы знаете, как Eventuate Local хранит события и снимки агрегатов. А сей
час посмотрим, как клиент подписывается на эти события с помощью брокера из
состава Eventuate Local.
Потребление событий с помощью подписки на брокер
из состава Eventuate Local
Сервис потребляет события, подписываясь на брокер, реализованный с помощью Apache
Kafka. У брокера событий есть тематика для каждого типа агрегата. Как объяснялось
в главе 3, тематика — это секционированный канал сообщений. Благодаря этому
потребитель может выполнять горизонтальное масштабирование, сохраняя порядок
следования сообщений. ID агрегата используется в качестве ключа секционирования,
который сохраняет порядок следования событий, публикуемых заданным агрегатом.
Для потребления событий агрегата сервис подписывается на его тематику.
Теперь поговорим о ретрансляторе — прослойке, соединяющей базу данных
и брокер событий.
Ретранслятор из состава Eventuate Local доставляет события
из базы данных к брокеру сообщений
Ретранслятор доставляет события, вставленные в базу данных, к брокеру. Он ис
пользует отслеживание транзакционного журнала, если оно поддерживается, или
опрашивание в случае с другими базами данных. Например, версия ретрансля
тора на основе MySQL применяет протокол репликации «ведущий/ведомый».
248 Глава 6 • Разработка бизнес-логики с порождением событий
Она подключается к серверу MySQL в роли ведомой стороны и считывает его двоич
ный журнал, в который записываются обновления базы данных. Записи о событиях,
вставленные в таблицу EVENTS, публикуются в подходящую тематику Apache Kafka.
Ретранслятор событий игнорирует изменения любых других видов.
Ретранслятор событий развертывается в виде автономного процесса. Для кор
ректного перезапуска он периодически сохраняет текущую позицию двоичного
журнала (имя файла и смещение) в специальной тематике Apache Kafka. В ходе
запуска он извлекает оттуда последнюю записанную позицию и начинает чтение
двоичного журнала MySQL из соответствующего места.
База данных, брокер сообщений и ретранслятор вместе формируют хранилище
событий. Теперь рассмотрим фреймворк, с помощью которого Java-приложения
обращаются к этому хранилищу.
6.2.2. Клиентский фреймворк Eventuate для Java
Фреймворк Eventuate Client (рис. 6.10) позволяет разработчикам писать прило
жения на основе порождения событий, которые используют хранилище Eventuate
Local. Он предоставляет фундамент для разработки агрегатов, сервисов и обработ
чиков с поддержкой порождения событий.
Рис. 6.10. Основные классы и интерфейсы, предоставляемые фреймворком Eventuate Client
для языка Java
6.2. Реализация хранилища событий 249
Этот фреймворк предоставляет базовые классы для агрегатов, команд и событий.
Есть также класс AggregateRepository, который реализует функции CRUD. Кроме
того, у него есть API для подписки на события.
Кратко рассмотрим каждый из типов, представленных на рис. 6.10.
Определение агрегатов с помощью
класса ReflectiveMutableCommandProcessingAggregate
Ref lectiveMutableCommandProcessingAggregate — это базовый обобщенный класс для
агрегатов. У него есть два типа параметров: конкретный класс агрегата и родитель
класса его команды. По довольно длинному имени можно догадаться, что он ис
пользует отражение для передачи команд и событий подходящим методам. Команды
отправляются методу process(), а события — методу apply().
Ref lectiveMutableCommandProcessingAggregate наследуется классом Order, кото
рый вы видели ранее. Его код показан в листинге 6.3.
Листинг 6.3. Класс Order в версии фреймворка Eventuate
public class Order extends ReflectiveMutableCommandProcessingAggregate<Order,
OrderCommand> {
public List<Event> process(CreateOrderCommand command) { ... }
public void apply(OrderCreatedEvent event) { ... }
}
Классу ReflectiveMutableCommandProcessingAggregate передаются два параме
тра: Order и Ordercommand. Последний является базовым интерфейсом для команд
агрегата Order.
Определение команд агрегата
Классы команд агрегата должны реализовывать его базовый интерфейс, который,
в свою очередь, должен наследовать интерфейс Command. Например, команды агрегата
Order реализуют интерфейс Ordercommand:
public interface Ordercommand extends Command {
}
public class CreateOrderCommand implements Ordercommand { ... }
Интерфейс Ordercommand наследует Command, а класс CreateOrderCommand реали
зует OrderCommand.
250 Глава 6 • Разработка бизнес-логики с порождением событий
Определение доменных событий
Классы событий агрегата должны реализовывать интерфейс-маркер Event, у которо
го нет методов. Также не помешает определить общий базовый интерфейс для всех
классов событий агрегата — он будет наследовать Event. Например, далее показано
определение класса OrderCreated:
interface OrderEvent extends Event {
}
public class OrderCreated extends OrderEvent { ... }
Класс событий OrderCreated наследует интерфейс OrderEvent, который является
базовым для классов событий агрегата Order. OrderEvent наследует Event.
Создание, поиск и обновление агрегатов
с помощью класса AggregateRepository
Фреймворк предоставляет несколько способов создания, поиска и обновления агре
гатов. Самый простой способ, который здесь описывается, состоит в использовании
обобщенного класса AggregateRepository, который параметризуется классом агре
гата и базовым классом его команд. Он предоставляет три перегруженных метода:
□ save() — создает агрегат;
□ f ind() — ищет агрегат;
□ update() — обновляет агрегат.
Особенно полезны методы save() и update(), так как они инкапсулируют в себе
шаблонный код, необходимый для создания и обновления агрегатов. Например,
save() принимает в качестве параметра командный объект и выполняет следующие
действия.
1. Создает экземпляр агрегата с помощью его конструктора по умолчанию.
2. Вызывает метод process(), чтобы обработать команду.
3. Применяет сгенерированные события путем вызова apply().
4. Сохраняет сгенерированные события в хранилище.
Метод update() работает похожим образом. Он принимает два параметра, ID
агрегата и команду, и выполняет следующие действия.
1. Извлекает агрегат из хранилища событий.
2. Вызывает метод process(), чтобы обработать команду.
3. Применяет сгенерированные события путем вызова арр1у().
4. Сохраняет сгенерированные события в хранилище.
6.2. Реализация хранилища событий 251
Класс AggregateRepository в основном используют сервисы, которые создают
и обновляют агрегаты в ответ на внешние запросы. Например, в листинге 6.4 по
казано, как OrderService создает Order с помощью AggregateRepository.
Листинг 6.4. OrderService использует AggregateRepository
public class OrderService {
private AggregateRepository<Order, OrderCommand> orderRepository;
public OrderService(AggregateRepository<Order, OrderCommand> orderRepository)
{
this.orderRepository = orderRepository;
}
public EntityWithIdAndVersion<Order> createOrder(OrderDetails orderDetails) {
return orderRepository.save(new CreateOrder(orderDetails));
}
}
AggregateRepository внедряется в сервис OrderService для работы с экземпля
рами Order. Метод сервиса create() делает вызов AggregateRepository.save(),
передавая команду CreateOrder.
Подписка на доменные события
Фреймворк Eventuate Client также предоставляет API для написания обработчи
ков событий. В листинге 6.5 показан обработчик событий типа CreditReserved.
Аннотация @EventSubscriber указывает идентификатор долговременной подписки.
Если в момент публикации событий подписчик недоступен, он получит их при за
пуске. Аннотация @EventHandlerMethod помечает метод creditReserved() в качестве
обработчика событий.
Листинг 6.5. Обработчик событий типа OrderCreatedEvent
@EventSubscriber(id="orderServiceEventHandlers")
public class OrderServiceEventHandlers {
@EventHandlerMethod
public void creditReserved(EventHandlerContext<CreditReserved> ctx) {
CreditReserved event = ctx.getEvent();
}
Обработчик событий принимает параметр типа EventHandlerContext, который
содержит само событие и его метаданные.
Итак, вы узнали, как писать бизнес-логику на основе порождения событий с по
мощью фреймворка Eventuate Client. Теперь посмотрим, как эту логику можно
применять совместно с повествованиями.
252 Глава 6 • Разработка бизнес-логики с порождением событий
6.3. Совместное использование повествований
и порождения событий
Представьте, что вы реализовали один или несколько сервисов на основе порожде
ния событий. Они, вероятно, похожи на пример, показанный в листинге 6.4. Но если
вы прочитали главу 4, то должны знать, что сервисам часто приходится иницииро
вать повествования — последовательности локальных транзакций, которые обеспе
чивают согласованность данных между сервисами, и участвовать в них. Например,
сервис Order применяет для проверки заказов повествование, в котором участвуют
также сервисы Kitchen, Consumer и Accounting. Следовательно, вам нужно интегри
ровать повествования и бизнес-логику на основе порождения событий.
Порождение событий облегчает использование повествований, основанных на
хореографии. Участники обмениваются доменными событиями, которые генериру
ются их агрегатами. Агрегаты каждого участника реагируют на события, обрабатывая
команды и генерируя новые события. Вам необходимо написать классы агрегатов
и обработчиков событий, которые будут обновлять агрегаты.
Однако интеграция бизнес-логики на основе порождения событий и повествова
ний с поддержкой оркестрации может создать дополнительные трудности. Дело в том,
что транзакции в понятии хранилища событий могут быть довольно ограниченными.
Некоторые хранилища позволяют создавать или обновлять лишь один агрегат с по
следующей публикацией события (-ий). Но каждый этап повествования состоит из
нескольких действий, которые необходимо выполнять атомарно.
□ Создание повествования. Сервис, инициирующий повествование, должен соз
дать его оркестратор и выполнить атомарное создание или обновление агрегата.
Например, метод createOrder() из сервиса Order должен создать агрегат Order
и повествование CreateOrderSaga.
□ Оркестрация повествования. Оркестратор повествования должен атомарно по
треблять ответы, обновлять его состояние и отправлять командные сообщения.
□ Участники повествования. Участники повествования, такие как сервисы Kitchen
и Order, должны атомарно потреблять сообщения, обнаруживать и отклонять
дубликаты, создавать или обновлять агрегаты и отправлять ответные сооб
щения.
Учитывая несоответствие между требованиями к хранилищу событий и его
транзакционными возможностями, интеграция повествований на основе оркестра
ции и порождения событий может создать некоторые интересные проблемы.
Ключевой фактор в интеграции этих двух технологий — выбор базы данных для
хранилища событий: СУРБД или NoSQL. Фреймворк Eventuate Tram, описанный
в главе 4, и фреймворк обмена сообщениями Tram, на котором он основан (см. гла
ву 3), полагаются на гибкие ACID-транзакции, предоставляемые СУРБД. Орке
стратор и участники повествования используют ACID-транзакции для атомарного
обновления своих баз данных и обмена сообщениями. Если вы задействуете храни
лище событий на основе СУРБД, такое как Eventuate Local, то можете сжульничать
6.3. Совместное использование повествований и порождения событий 253
и обновить его в рамках ACID-транзакции, обратившись к фреймворку Eventuate
Tram. Но если хранилище событий основано на базе данных NoSQL, которая не мо
жет участвовать в одной транзакции с фреймворком Eventuate Tram, следует при
менить другой подход.
Подробно рассмотрим разные сценарии и проблемы, которые вам, возможно,
придется решать.
□ Реализация повествований на основе хореографии.
□ Создание повествований на основе оркестрации.
□ Реализация участника повествования, основанного на порождении событий.
□ Реализация оркестраторов повествований с помощью порождения событий.
Начнем с обсуждения того, как реализовать повествование на основе хореогра
фии, используя порождение событий.
6.3.1. Реализация повествований на основе
хореографии с помощью порождения событий
Событийная природа шаблона «Порождение событий» делает реализацию повество
ваний на основе хореографии довольно прямолинейной. При обновлении агрегат
генерирует событие. У другого агрегата может быть обработчик, который обновляет
его в результате получения этого события. Фреймворк для порождения событий
автоматически делает все обработчики идемпотентными.
В главе 4 обсуждалось, как с помощью хореографии реализовать повествование
Create Order. Consumerservice, KitchenService и Accountingservice подписываются
на события сервиса OrderService, и наоборот. У каждого сервиса есть обработчик
событий, аналогичный показанному в листинге 6.5. Он обновляет соответствующий
агрегат, который генерирует другое событие.
Порождение событий и повествования на основе хореографии отлично соче
таются друг с другом. Порождение событий предоставляет механизмы, которые
нужны повествованиям, включая IPC на основе обмена сообщениями, дедуплика
цию сообщений, а также атомарные обновление состояния и отправку сообщений.
Несмотря на свою простоту, повествования на основе хореографии имеют несколько
недостатков. Некоторые из них перечислены в главе 4, но есть и такие, которые от
носятся непосредственно к порождению событий.
Проблема использования событий для хореографии повествований состоит в их
двойном назначении. В шаблоне «Порождение событий» они описывают изменение
состояния, но в хореографии повествований должны генерироваться агрегатом, даже
если состояние не меняется. Например, если обновление агрегата нарушает бизнес-
правило, тот должен сгенерировать событие, чтобы сообщить об ошибке. Еще более
серьезная проблема может возникнуть, когда участнику повествования не удается
создать агрегат, в этом случае вернуть ошибку попросту некому.
Учитывая эти трудности, более сложные повествования лучше реализовывать
с помощью оркестрации. В следующем разделе объясняется, как интегрировать
254 Глава 6 • Разработка бизнес-логики с порождением событий
повествования на основе оркестрации и порождение событий. Как вы увидите, это
потребует решения довольно интересных проблем.
Сначала посмотрим, как метод сервиса, такой как OrderService.createOrder(),
создает оркестратор повествования.
6.3.2. Создание повествования на основе оркестрации
Оркестраторы повествований создаются некими методами сервиса. Другие методы,
такие как OrderService.createOrder(), делают две вещи: создают или обновляют
агрегат и создают оркестратор повествований. Сервис должен выполнять эти два
действия так, чтобы второе гарантированно завершалось в случае успешного вы
полнения первого. То, как сервис этого добивается, зависит от того, какого рода
хранилище событий он использует.
Создание оркестратора повествований с помощью
хранилища событий на основе СУРБД
Если сервис использует хранилище событий на основе СУРБД, он может его об
новить и создать оркестратор повествований в рамках одной ACID-транзакции.
Представьте, к примеру, что сервис OrderService задействует хранилище Eventuate
Local и фреймворк Eventuate Tram. В этом случае его метод createOrder() будет
выглядеть так:
class OrderService Делаем так, чтобы метод areateOrderO
выполнялся в рамках транзакции БД
gAutowired
private SagaManager<CreateOrderSagaState> createOrderSagaManager;
^Transactional ◄-------
public EntityWithIdAndVersion<Order> createOrder(OrderDetails orderDetails) {
EntityWithIdAndVersion<Order> order =
orderRepository.save(new CreateOrder(orderDetails)); <■ Создаем
агрегат Order
CreateOrderSagaState data =
new CreateOrderSagaState(order.getId(), orderDetails); < Создаем
CreateOrderSaga
createOrderSagaManager.create(data, Order.class, order.getld());
return order;
}
Это комбинация листинга 6.4 и сервиса OrderService, описанного в главе 4.
Поскольку хранилище Eventuate Local использует СУРБД, оно может участвовать
в той же транзакции, что и фреймворк Eventuate Tram. Но если сервис задействует
хранилище на основе NoSQL, создание оркестратора повествований оказывается
не таким простым.
6.3. Совместное использование повествований и порождения событий 255
Создание оркестратора повествований при использовании
хранилища событий на основе NoSQL
Сервис, применяющий хранилище событий на основе NoSQL, скорее всего, не смо
жет атомарно обновить его и создать оркестратор повествований. Фреймворк орке
страции может использовать совершенно другую базу данных. Но даже если это та
же БД, из-за ограниченной транзакционной модели NoSQL приложение не сможет
создать или обновить два разных объекта атомарно. Вместо этого у сервиса должен
быть предусмотрен обработчик, который создает оркестратор повествований в ответ
на доменное событие, сгенерированное агрегатом.
Например, на рис. 6.11 показано, как сервис Order создает CreateOrderSaga с по
мощью обработчика событий типа OrderCreated. Сначала он создает агрегат Order
и сохраняет его в хранилище событий. Хранилище публикует событие OrderCreated,
которое потребляется обработчиком. Затем обработчик обращается к фреймворку
Eventuate Tram, чтобы создать CreateOrderSaga.
Рис. 6.11. Использование обработчика для надежного создания повествования после того,
как сервис создаст агрегат на основе порождения событий
При написании обработчика, который создает оркестратор повествований, сле
дует помнить, что он должен уметь справляться с повторяющимися событиями.
Доставка сообщения не менее одного раза означает, что обработчик событий, созда
ющий повествование, может быть вызван несколько раз. Важно сделать так, чтобы
повествование создавалось в единственном экземпляре.
256 Глава 6 • Разработка бизнес-логики с порождением событий
Одно из простых решений заключается в формировании идентификатора по
вествования на основе уникального атрибута события. Это можно сделать несколь
кими разными способами. Например, в качестве идентификатора повествования
можно использовать ID агрегата, который генерирует событие. Это хорошо под
ходит для повествований, которые создаются в ответ на генерацию событий со
стороны агрегата.
Вместо этого для идентификации повествования можно воспользоваться ID
события. Поскольку эти ID уникальные, это гарантирует уникальность идентифи
катора повествования. Если событие окажется дубликатом, попытка обработчика
создать повествование завершится неудачно, потому что этот ID уже существует.
Рассмотренный вариант подходит в ситуациях, когда для заданного экземпляра
агрегата могут существовать несколько копий одного и того же повествования.
Сервис, использующий хранилище событий на основе СУРБД, тоже может при
менять этот событийный подход к созданию повествований. Его преимущество в том,
что он поощряет слабое связывание, поскольку сервисы наподобие OrderService
больше не занимаются созданием экземпляров повествований напрямую.
Итак, вы научились создавать оркестраторы повествований. Теперь посмотрим,
как сервисы, основанные на порождении событий, могут участвовать в оркестриру-
емых повествованиях.
6.3.3. Реализация участника повествования
на основе порождения событий
Представьте, что вы используете порождение событий для реализации сервиса, ко
торому нужно участвовать в повествовании на основе оркестрации. Неудивительно,
что, если хранилище событий вашего сервиса (например, Eventuate Local) основано на
СУРБД, вы гарантируете атомарную обработку командных сообщений и возвращение
ответов. Сервис может обновлять хранилище в рамках AC ID-транзакции, иницииро
ванной фреймворком Eventuate Tram. Но если вы выбрали для своего сервиса хра
нилище событий, которое не может участвовать в одной транзакции с фреймворком
Eventuate Tram, то придется использовать совершенно другой подход.
Вы должны решить две разные проблемы, обеспечив:
□ идемпотентность обработки командных сообщений;
□ атомарную отправку ответного сообщения.
Сначала посмотрим, как реализовать идемпотентные обработчики командных
сообщений.
Идемпотентная обработка командных сообщений
Первая проблема, которую нужно решить, — то, как участник повествования, осно
ванного на порождении событий, будет обнаруживать и отклонять повторяющиеся
сообщения. Это необходимо для реализации идемпотентной обработки командных
6.3. Совместное использование повествований и порождения событий 257
сообщений. К счастью, эту проблему легко решить с помощью механизма идемпо
тентной обработки сообщений, описанного ранее. Участник повествования записы
вает ID сообщения в события, которые генерируются при его обработке. Прежде чем
обновлять агрегат, участник сверяет ID сообщения в событиях и убеждается в том,
что он его еще не обрабатывал.
Атомарная отправка ответных сообщений
Вторая проблема связана с атомарной отправкой ответов участником повествования,
основанного на порождении событий. В принципе, оркестратор может подписать
ся на события, генерируемые агрегатом, но у этого решения есть два недостатка.
Во-первых, команда повествования может и не поменять состояние агрегата. В этом
случае он не сгенерирует событие, поэтому оркестратору не будет отправлено ника
кого ответа. Во-вторых, этот подход требует, чтобы оркестратор различал участников
в зависимости от того, используют они порождение событий или нет. Это связано
с тем, что для получения доменных событий оркестратор должен подписаться
не только на собственный канал ответов, но и на канал событий агрегата.
У этой проблемы есть более подходящее решение. Нужно сделать так, чтобы
участник повествования продолжал отправлять ответные сообщения в канал от
ветов оркестратора. Но вместо того, чтобы выполнять это напрямую, он должен
реализовать двухшаговый процесс.
1. Когда обработчик команд повествования создает или обновляет агрегат, он делает
так, чтобы псевдособытие SagaReplyRequested сохранялось в хранилище вместе
с настоящими событиями, сгенерированными агрегатом.
2. Обработчик псевдособытия SagaReplyRequested использует содержащиеся в нем
данные для формирования ответного сообщения, которое затем записывает в ка
нал ответов оркестратора повествований.
Рассмотрим пример, чтобы понять, как это работает.
Пример участника повествования
на основе порождения событий
В этом примере мы сосредоточимся на сервисе Accounting — одном из участников
повествования Create Order. На рис. 6.12 показано, как он обрабатывает команду
Authorize, отправленную повествованием. Сервис Accounting реализован с помощью
фреймворка Eventuate Saga, который имеет открытый исходный код и предназначен
для написания повествований с использованием порождения событий. Он основан
на фреймворке Eventuate Client.
На этом рисунке показано, как взаимодействуют повествование Create Order
и сервис Accounting. Наблюдается такая последовательность событий.
1. Повествование Create Order шлет сервису Accounting команду AuthorizeAccount
через канал сообщений. SagaCommandDispatcher из фреймворка Eventuate Saga
258 Глава 6 • Разработка бизнес-логики с порождением событий
вызывает AccountingServiceCommandHandler, чтобы обработать командное со
общение.
2. AccountingServiceCommandHandler отправляет команду заданному агрегату
Account.
3. Агрегат генерирует два события — AccountAuthorized и SagaReplyRequestedEvent.
4. SagaReplyRequestedEventHandler обрабатывает SagaReplyRequestedEvent и от
правляет ответное сообщение повествованию Create Order.
Рис. 6.12. Как сервис Accounting, основанный на порождении событий, участвует
в повествовании Create Saga
6.3. Совместное использование повествований и порождения событий 259
Класс AccountingServiceCommandHandler, показанный в листинге 6.6, обраба
тывает командное сообщение AuthorizeAccount. Для этого он вызывает метод
AggregateRepository.update(), чтобы обновить агрегат Account.
Листинг б.б. Обрабатывает командные сообщения, отправленные повествованиями
public class AccountingServiceCommandHandler {
gAutowired
private AggregateRepository<Account, AccountCommand> accountRepository;
public void authorize(CommandMessage<AuthorizeCoinmand> cm) {
AuthorizeCommand command = cm.getCommand();
accountRepository.update(command.getOrderld(),
command,
replyingTo(cm)
.catching(AccountDisabledException.class,
() -> withFailure(new AccountDisabledReply()))
.build());
}
Метод authorize() использует AggregateRepository, чтобы обновить агрегат
Account. Третий аргумент метода update() (UpdateOptions) вычисляется с помощью
следующего выражения:
replyingTo(cm)
.catching(AccountDisabledException.class,
() -> withFailure(new AccountDisabledReply()))
.build()
Параметры UpdateOptions конфигурируют метод update() для выполнения сле
дующих действий.
1. Идентификатор сообщения используется в качестве ключа идемпотентности,
чтобы сообщение всегда обрабатывалось только один раз. Как упоминалось ранее,
фреймворк Eventuate хранит ключ идемпотентности во всех сгенерированных
событиях, благодаря чему может обнаруживать и игнорировать любые повторные
попытки обновления агрегата.
2. Псевдособытие SagaReplyRequestedEvent добавляется в список событий, по
мещенный в хранилище. Когда обработчик SagaReplyRequestedEventHandler
получает псевдособытие SagaReplyRequestedEvent, он отправляет ответ в канал
ответов CreateOrderSaga.
3. Когда агрегат генерирует исключение AccountDisabledException, вместо стан
дартной ошибки отправляется ответ AccountDisabledReply.
Итак, вы увидели, как реализовать участников повествования с помощью по
рождения событий. Теперь рассмотрим реализацию оркестраторов.
260 Глава 6 • Разработка бизнес-логики с порождением событий
6.3.4. Реализация оркестраторов повествований
с помощью порождения событий
До сих пор в этой главе мы обсуждали то, как сервисы, основанные на порождении
событий, могут участвовать в повествованиях. Но порождение событий можно ис
пользовать и для реализации оркестраторов. Это позволит вам разрабатывать при
ложения, полностью основанные на хранилище событий.
При разработке оркестратора повествований необходимо решить три ключевые
проблемы, связанные с проектированием.
1. Как будет храниться оркестратор?
2. Как атомарно изменять состояние оркестратора и слать командные сообщения?
3. Как убедиться в том, что оркестратор обрабатывает ответные сообщения ровно
один раз?
В главе 4 мы обсуждали реализацию оркестратора на основе СУРБД. Теперь по
смотрим, как решить эти проблемы с помощью порождения событий.
Сохранение оркестратора повествований
с помощью порождения событий
У оркестратора повествований очень простой жизненный цикл. Сначала он создает
ся, затем обновляется в ответ на сообщения, возвращаемые участниками повество
вания. Таким образом, мы можем сохранить оркестратор, используя следующие
события:
□ SagaOrchestratorCreated — оркестратор повествований создан;
□ SagaOrchestratorUpdated — оркестратор повествований обновлен.
Оркестратор генерирует событие SagaOrchestratorCreated, когда его создают,
и SagaOrchestratorUpdated — когда обновляют. Эти события содержат данные,
необходимые для воссоздания состояния оркестратора. Например, события для
CreateOrderSaga, описанные в главе 4, содержали бы сериализованную версию
CreateOrderSagaState (например, в формате JSON).
Надежная отправка командных сообщений
Еще одной ключевой проблемой проектирования является обновление состояния
повествования и отправка команд атомарным образом. Как описывалось в главе 4,
при реализации повествований на основе Eventuate Tram обновление оркестратора
и вставка командного сообщения в таблицу message происходят в рамках одной
транзакции. Приложение, использующее хранилище событий на основе СУРБД,
такое как Eventuate Local, может применять этот же подход. Но вы можете задей
ствовать аналогичное решение, даже если хранилище основано на NoSQL (как, на
пример, в Eventuate SaaS) и имеет ограниченную транзакционную модель.
6.3. Совместное использование повествований и порождения событий 261
Главное здесь — сохранить событие SagaCommandEvent, представляющее коман
ду, которую нужно отправить. После этого обработчик событий подпишется на
SagaCommandEvent и отправит каждое командное сообщение в подходящий канал.
На рис. 6.13 показано, как это работает.
д
К(
Рис. 6.13. Как оркестратор на основе порождения событий рассылает команды участникам
повествования
Команды отправляются в два этапа.
1. Оркестратор генерирует событие SagaCommandEvent для каждой команды, которую
он хочет отправить. SagaCommandEvent содержит все данные, необходимые для от
правки команды, включая канал назначения и командный объект. Эти события
находятся в хранилище.
2. Обработчик принимает события SagaCommandEvent и отправляет командные со
общения в заданный канал.
Двухшаговый процесс гарантирует, что команда будет отправлена хотя бы
один раз.
Поскольку хранилище обеспечивает как минимум одноразовую доставку, об
работчик может быть вызван несколько раз с одним и тем же событием. Если это
случится, обработчик событий SagaCommandEvent пошлет несколько одинаковых
командных сообщений. К счастью, участник повествования может с легкостью об
наружить и отклонить повторяющиеся команды с помощью следующего механизма.
262 Глава 6 • Разработка бизнес-логики с порождением событий
Идентификатор SagaCommandEvent, уникальность которого гарантируется, исполь
зуется в качестве ID командного сообщения. В итоге у дубликатов будет один и тот
же ID. Участник повествования, который получает повторяющееся командное со
общение, отклонит его при помощи механизма, описанного ранее.
Обработка ответов ровно один раз
Оркестратор повествований должен обнаруживать и отклонять повторяющиеся от
ветные сообщения. Для этого он может использовать описанный ранее механизм.
Оркестратор сохраняет ID ответного сообщения в события, которые он генерирует
при обработке ответа. Затем он легко может определить, является ли сообщение
дубликатом.
Как видите, порождение событий служит хорошей основой для реализации по
вествований. У этого шаблона есть и другие преимущества, включая изначально
надежную генерацию событий при любом изменении данных, надежное ведение
журнала аудита и выполнение временных запросов. Но нужно понимать, что это
не панацея. Порождение событий имеет высокий порог вхождения. Изменение
структуры событий не всегда проходит гладко. Но, несмотря на эти недостатки,
порождение событий играет важную роль в микросервисной архитектуре. В следу
ющей главе мы сменим тему и рассмотрим другой аспект, связанный с управлением
распределенными данными в микросервисах, — запросы. Я покажу, как реализовать
запросы, которые извлекают данные из разных сервисов.
Резюме
□ Порождение событий сохраняет агрегат в виде последовательности событий.
Каждое событие описывает либо создание агрегата, либо изменение его состоя
ния. Для воссоздания состояния агрегата приложение воспроизводит события.
Этот шаблон сохраняет историю доменного объекта, предоставляет точный
журнал аудита и делает возможной надежную публикацию доменных событий.
□ Снимки улучшают производительность, уменьшая количество событий, которые
нужно воспроизводить.
□ События размещаются в хранилище — гибриде базы данных и брокера сообще
ний. Когда сервис помещает событие в хранилище, он тем самым доставляет его
подписчикам.
□ Eventuate Local — это хранилище событий с открытым исходным кодом, основан
ное на MySQL и Apache Kafka. Разработчики используют фреймворк Eventuate
Client для написания агрегатов и обработчиков событий.
□ Одна из проблем порождения событий связана с их развитием. При воспроиз
ведении событий приложение может обрабатывать разные их версии. Хорошим
решением является приведение к базовому типу, когда события обновляются до
последней версии во время загрузки из хранилища.
Резюме 263
□ Удаление данных в приложении на основе порождения событий связано с опреде
ленными трудностями. Существуют нормативно-правовые требования, например
Общий регламент по защите данных в Европейском союзе. Для их соблюдения вы
должные использовать такие методики, как шифрование и псевдоанонимизация,
чтобы иметь возможность удалять данные пользователей.
□ Порождение событий упрощает реализацию повествований, основанных на
хореографии. У сервисов есть обработчики, которые отслеживают события, пу
бликуемые агрегатами.
□ Порождение событий — хороший подход к реализации оркестраторов повество
ваний. Благодаря ему вы можете писать приложения, использующие исключи
тельно хранилище событий.
Реализация запросов
в микросервисной
архитектуре
Мэри и ее команда начали привыкать к идее использования повествований для обе
спечения согласованности данных. Но затем они обнаружили, что управление транз
акциями было не единственной проблемой, связанной с распределенными данными,
которую нужно решить при переводе приложения FTGO на микросервисы. Помимо
прочего, им следовало разобраться с тем, как реализовывать запросы.
Для поддержки пользовательского интерфейса приложение FTGO реализует
целый ряд запросов. Их реализация в существующей монолитной архитектуре
была довольно прямолинейной, потому что они использовали единую базу данных.
Разработчикам FTGO в основном было достаточно написать SQL-выражения вроде
SELECT и определить необходимые индексы. Но, как обнаружила Мэри, написание
запросов в микросервисных системах сопряжено с определенными трудностями.
Запросы часто должны извлекать данные, разбросанные по базам данных, принад
лежащим разным сервисам. При этом нельзя задействовать традиционный меха
низм распределенных запросов — даже если бы это было технически возможно, это
нарушало бы инкапсуляцию.
Возьмем, к примеру, запросы для приложения FTGO, описанные в главе 2.
Некоторые из них извлекают данные, принадлежащие только одному сервису.
7.1. Выполнение запросов с помощью объединения API 265
Например, запрос findConsumerProfile() возвращает данные из сервиса Consumer.
Но такие операции, как findOrder () и findOrderHistory(), возвращают информацию,
которой владеют несколько сервисов. Их реализация будет не такой простой.
Существует два шаблона для написания запросов в микросервисной архитек
туре.
□ Объединение API. Это самый простой подход, который следует использовать
везде, где возможно. Он заключается в том, что за обращение к сервисам и объ
единение результатов отвечают клиенты.
□ Разделение ответственности командных запросов. У этого шаблона, известного
как CQRS, больше возможностей по сравнению с предыдущим, но при этом он
сложнее. Он требует наличия одной или нескольких баз данных, единственное
назначение которых заключается в поддержке запросов.
После обсуждения этих шаблонов поговорим о проектировании представлений
CQRS, один из примеров которых будет реализован чуть позже. Начнем с объеди
нения API.
7.1. Выполнение запросов с помощью
объединения API
Приложение FTGO реализует целый ряд запросов. Некоторые из них, как упоми
налось ранее, извлекают информацию из одного сервиса. Их реализация обычно
не вызывает проблем, хотя позже в этой главе при рассмотрении шаблона CQRS
вы увидите пример того, как обращение к одному сервису создает определенные
трудности.
Существуют также запросы, которые извлекают данные из нескольких сервисов.
В этом разделе я опишу операцию findOrder() — пример такого запроса. Я объясню,
какие проблемы часто возникают при реализации подобных операций в микро
сервисной архитектуре. Затем опишу шаблон объединения API и покажу, как с его
помощью можно реализовать запросы наподобие findOrder().
7.1.1. Запрос findOrder()
Операция findOrder() извлекает заказ по его первичному ключу. Она принимает
orderld в качестве параметра и возвращает объект OrderDetails, который содержит
информацию о заказе. Как показано на рис. 7.1, эта операция вызывается клиентским
модулем, который реализует представление Order Status, — например, мобильным
устройством или веб-приложением.
Сведения, которые выводит Order Status, включают в себя основную информацию
о состоянии заказа, платежа, выполнении заявки с точки зрения ресторана, а также
местоположении и предполагаемом времени доставки, если заказ уже в пути.
266 Глава 7 • Реализация запросов в микросервисной архитектуре
Рис. 7.1. Операция findOrder() вызывается клиентским модулем FTGO
и возвращает подробности о заказе
Поскольку монолитная версия FTGO хранит свои данные в одной БД, она может
легко извлечь подробности о заказе с помощью единственного выражения SELECT,
которое объединяет различные таблицы. Тогда как в микросервисном приложении
FTGO информация разбросана но следующим сервисам:
□ Order — основная информация, включая подробности и статус;
□ Kitchen — статус заказа с точки зрения ресторана и то, когда он должен быть
готов к доставке;
□ Delivery — состояние доставки заказа, предполагаемая информация о доставке
и местоположение курьера;
□ Accounting — состояние оплаты заказа.
Любой клиент, которому нужны подробности о заказе, должен опросить все эти
сервисы.
7.1.2. Обзор шаблона «Объединение API»
Для реализации таких запросов, как findOrder(), которые извлекают данные, при
надлежащие разным сервисам, можно использовать шаблон «Объединение API».
Чтобы выполнить запрос, он обращается к сервисам, которым принадлежат нужные
7.1. Выполнение запросов с помощью объединения API 267
данные, и объединяет результаты. Структура этого шаблона показана на рис. 7.2.
Он предусматривает два вида участников:
□ API-композитор — реализует операцию запроса, обращаясь к сервисам-провай
дерам;
□ сервис-провайдер — сервис, которому принадлежат данные, возвращаемые за
просом.
Рис. 7.2. Шаблон «Объединение API» состоит из API-композитора
и двух или более сервисов-провайдеров. API-композитор реализует запрос,
обращаясь к провайдерам и объединяя результаты
На рис. 7.2 показаны три сервиса-провайдера. API-композитор реализует за
прос, извлекая данные из провайдеров и объединяя результаты. Он может быть
как клиентом, таким как веб-приложение, которому нужны данные для отрисовки
страницы, так и сервисом — например, API-шлюзом с серверами. Последний вариант
описан в главе 8 на примере операции запроса, которая делается доступной в виде
конечной точки API.
Подходит ли этот шаблон для реализации конкретного запроса, зависит от
нескольких факторов, включая способ сегментирования информации, возмож
ности API, которые предоставляют владельцы данных, а также возможности БД,
268 Глава 7 • Реализация запросов в микросервисной архитектуре
используемых сервисами. Например, даже если у сервиса-провайдера предусмо
трен API для извлечения нужной информации, агрегатору, возможно, придется
выполнить неэффективное соединение крупных наборов данных в памяти. Позже
вы познакомитесь с примерами запросов, которые нельзя реализовать с помощью
этого шаблона. К счастью, существует множество ситуаций, к которым его можно
применить. Чтобы увидеть его в действии, рассмотрим пример.
7.1.3. Реализация запроса findOrder() путем
объединения API
Операция f indOrder () соответствует простому запросу по первичному ключу с объ
единением на основе равенства (equijoin). Логично предположить, что в API каждого
из сервисов-провайдеров есть конечная точка для извлечения нужных данных по
orderld. Следовательно, запрос findOrder() — отличный кандидат для того, чтобы
быть реализованным с помощью шаблона «Объединение API». API-композитор
обращается к четырем сервисам и объединяет полученные результаты. Структура
композитора Find Order показана на рис. 7.3.
Рис. 7.3. Реализация findOrder() путем объединения API
В этом примере в качестве API-композитора выступает сервис, который делает
запрос доступным в виде конечной точки REST. Сервисы-провайдеры тоже реализу
ют REST API. Но, даже если бы сервисы общались не по HTTP, а по какому-то дру
гому протоколу межпроцессного взаимодействия, например gRPC, принцип работы
7.1. Выполнение запросов с помощью объединения API 269
был бы тем же самым. Композитор Find Order реализует конечную точку REST GET
/order/{orderld}. Он обращается к четырем сервисам и объединяет их ответы с по
мощью поля orderld. Каждый сервис-провайдер реализует конечную точку REST,
которая возвращает ответ, соответствующий отдельному агрегату. OrderService
извлекает свою версию Order по первичному ключу, а другие сервисы используют
orderld в качестве внешнего ключа для извлечения собственных агрегатов.
Как видите, в объединении API нет ничего сложного. Теперь обсудим несколько
архитектурных проблем, которые необходимо решить с помощью этого шаблона.
7.1.4. Архитектурные проблемы объединения API
При использовании этого шаблона необходимо решить две архитектурные про
блемы.
□ Какой компонент вашей архитектуры будет выступать API-композитором для
операции запроса.
□ Как написать эффективную логику агрегации.
Рассмотрим их.
Кто играет роль API-композитора
Одно из решений, которые вы должны принять, связано с тем, кто будет выступать
API-композитором для операции запроса. У вас есть три кандидата на эту роль.
Первый, клиент сервисов, показан на рис. 7.4.
Рис. 7.4. Реализация объединения API на уровне клиента. Клиент запрашивает данные
у сервисов-провайдеров
270 Глава 7 • Реализация запросов в микросервисной архитектуре
Такой клиент, как веб-приложение, реализующий представление Order Status
и работающий в той же локальной сети, может эффективно извлечь подробности
о заказе, используя данный шаблон. Но, как вы увидите в главе 8, этот вариант мо
жет не подойти для клиентов, которые находятся по другую сторону брандмауэра
и обращаются к сервисам по медленной сети.
Второй кандидат на роль API-композитора — API-шлюз (рис. 7.5), реализующий
внешний API приложения.
Рис. 7.5. Реализация объединения API внутри API-шлюза. Шлюз обращается к сервисам-провайдерам,
чтобы извлечь данные, объединяет результаты и возвращает ответ клиенту
Этот вариант имеет смысл, если операция запроса входит в состав внешнего
API. Вместо перенаправления запросов к другому сервису API-шлюз реализует
логику объединения API. Такой подход позволяет клиенту (например, мобиль
ному устройству), который находится за пределами брандмауэра, эффективно из
влекать данные из многочисленных сервисов с помощью единственного API-вызова.
API-шлюз обсуждается в главе 8.
Третьий кандидат на роль API-композитора — отдельный сервис (рис. 7.6).
Этот вариант следует использовать для запросов, применяемых разными внутрен
ними сервисами. Эту операцию могут задействовать также запросы, доступные извне,
чья логика агрегации слишком сложна для того, чтобы делать ее частью API-шлюза.
7.1. Выполнение запросов с помощью объединения API 271
Рис. 7.6. Реализация операции запроса, которую используют разные клиенты и сервисы,
в виде отдельного сервиса
API-композиторы должны использовать реактивную
модель программирования
При разработке распределенных систем всегда нужно минимизировать латент
ность. Чтобы сократить время ответа на запрос, API-композитор должен по воз
можности распараллеливать вызовы к сервисам-провайдерам. Например, агрегатор
Find Order должен параллельно обратиться к четырем сервисам, поскольку между
этими обращениями нет никаких зависимостей. Однако иногда API-композитору
нужен ответ одного из сервисов-провайдеров, чтобы обратиться к другому. В этом
случае некоторые (но, надеюсь, не все) сервисы необходимо вызывать последова
тельно.
Логика сочетания последовательных и параллельных обращений к сервисам мо
жет оказаться довольно сложной. Чтобы API-композитор был прост в обслуживании,
но при этом быстро работал и хорошо масштабировался, он должен использовать
методы реактивного проектирования на основе Java-класса CompletableFuture, на
блюдаемых объектов из состава Rxjava или аналогичных абстракций. Мы подробно
обсудим эту тему в главе 8 при рассмотрении шаблона «API-шлюз».
272 Глава 7 • Реализация запросов в микросервисной архитектуре
7.1.5. Преимущества и недостатки
объединения API
Этот шаблон — простой и интуитивно понятный способ реализации запросов
в микросервисной архитектуре. Но у него есть некоторые недостатки:
□ дополнительные накладные расходы;
□ риск снижения доступности;
□ нехватка транзакционной согласованности данных.
Рассмотрим их.
Дополнительные накладные расходы
Одной из отрицательных сторон этого шаблона являются накладные расходы при
обращении к нескольким сервисам и базам данных. В монолитном приложении
клиент может извлечь данные с помощью одного запроса, который выполняет
лишь одно обращение к БД. Для сравнения: объединение API подразумевает
выполнение нескольких запросов и обращений к БД. Это требует больше вычис
лительных и сетевых ресурсов, из-за чего обслуживание приложения становится
более накладным.
Риск снижения доступности
Еще один недостаток шаблона связан со снижением уровня доступности. Как гово
рилось в главе 3, доступность операции снижается с увеличением количества вовле
ченных в нее сервисов. Поскольку реализация запроса разделена между тремя или
более участниками {API-композитор и по меньшей мере два сервиса-провайдера),
ее доступность будет куда ниже, чем при использовании одного сервиса. Напри
мер, если доступность отдельного сервиса 99,5 %, то доступность конечной точки
findOrder(), которая обращается к четырем сервисам-провайдерам, снижается
до 99,5 %(4 + 1) = 97,5 %!
Существует несколько стратегий для улучшения доступности. Если сервис-про
вайдер недоступен, API-композитор может вернуть предварительно закэширован-
ные данные. Иногда данные, возвращаемые сервисом-провайдером, кэшируются для
повышения производительности, но с их помощью можно также улучшить доступ
ность. Если провайдер не отвечает, API-композитор может вернуть данные из кэша,
хотя при этом они могут оказаться устаревшими.
Еще одна стратегия улучшения доступности заключается в возвращении непол
ной информации. Представьте, к примеру, что сервис Kitchen временно недоступен.
API-композитор для операции f indOrder () может просто убрать данные этого сер
виса из ответа, поскольку даже без них пользовательскому интерфейсу будет что
показать. Больше подробностей о проектировании, кэшировании и надежности API
вы найдете в главе 8.
7.2. Применение шаблона CQRS 273
Нехватка транзакционной согласованности данных
Еще одним недостатком объединения API является нехватка транзакционной со
гласованности данных. В монолитных приложениях запросы обычно выполняются
в рамках одной транзакции базы данных. ACID-транзакции (при условии использо
вания определенных уровней изоляции) гарантируют, что для приложения данные
будут выглядеть согласованными даже во время выполнения нескольких запросов
к БД. Для сравнения: при объединении API разные запросы направляются к разным
базам данных. Поэтому существует риск того, что запрос вернет несогласованную
информацию.
Например, заказ, извлеченный из сервиса Order, может находиться в состоянии
CANCELLED, тогда как соответствующую заявку, полученную из сервиса Kitchen, еще
не успели отменить. API-композитор должен устранить это несоответствие, что
сделает его код более сложным. Что еще хуже, в некоторых случаях АР1-композитор
может не обнаружить, что данные не согласованы, и вернуть их клиенту.
Несмотря на эти недостатки, объединение API чрезвычайно полезно. Его можно
применять для реализации множества запросов. Но есть такие операции, которые
делают невозможной эффективную реализацию этого шаблона. Например, опера
ция запроса может требовать, чтобы API-композитор объединил в памяти большие
наборы данных.
Запросы такого рода лучше проектировать с использованием шаблона CQRS,
о котором речь пойдет в дальнейшем.
7.2. Применение шаблона CQRS
Многие промышленные приложения используют СУРБД в качестве транзакцион
ной системы записей, оставляя полнотекстовые запросы таким базам данных, как
Elasticsearch или Solr. Некоторые приложения для поддержания синхронности
одновременно записывают информацию в разные БД. Другие периодически ко
пируют данные из СУРБД в поисковую систему. Такая архитектура использует
преимущества нескольких баз данных: транзакционные свойства СУРБД и возмож
ности полнотекстового поиска.
Обобщенной версией этой архитектуры является CQRS (command query respon
sibility segregation — разделение ответственности командных запросов). Она за
действует одно или несколько представлений базы данных (не только поисковой
274 Глава 7 • Реализация запросов в микросервисной архитектуре
системы), которые реализуют как минимум один запрос приложения. Чтобы по
нять, в чем польза этого подхода, рассмотрим несколько запросов, которые нельзя
эффективно реализовать с помощью объединения API. Я объясню принцип работы
CQRS и расскажу о достоинствах и недостатках этого шаблона. Давайте посмотрим,
в каких ситуациях его нужно применять.
7.2.1. Потенциальные причины использования CQRS
Объединение API хорошо подходит для реализации многих запросов, которые
должные извлекать данные из разных сервисов. К сожалению, в микросервисной
архитектуре это лишь частичное решение проблемы. Существует множество запро
сов, которые нельзя эффективно спроектировать с помощью этого шаблона.
Кроме того, трудности могут возникнуть и с запросами, не выходящими за преде
лы одного сервиса. Возможно, база данных сервиса не поддерживает эффективные
запросы, а иногда запрос лучше спроектировать таким образом, чтобы он запрашивал
информацию, принадлежащую другому сервису. Обсудим эти проблемы. Начнем
с многосервисных запросов, которые нельзя эффективно реализовать с помощью
объединения API.
Реализация операции запроса findOrderHistory()
Операция findOrderHistory() извлекает историю заказов клиента. Она имеет не
сколько параметров.
□ consumerld — идентифицирует клиента.
□ pagination — страница результатов, которую нужно вернуть.
□ filter — фильтр по критериям, таким как максимальная давность возвращаемых
заказов, необязательный статус заказа и опциональные ключевые слова, которые
соответствуют названию ресторана и пунктам меню.
Этот запрос возвращает объект OrderHistory, который содержит краткую инфор
мацию о подходящих заказах, отсортированных по давности в порядке возрастания.
Он вызывается модулем, реализующим представление Order History. Это пред
ставление выводит краткое описание каждого заказа, включая его номер, статус,
итоговую сумму и ожидаемое время доставки.
На первый взгляд эта операция похожа на findOrder(). Единственное отличие
в том, что она возвращает несколько заказов вместо одного. Вам может показаться, что
API ^композитору достаточно выполнить один и тот же запрос для каждого сервиса-
провайдера и затем объединить результаты. К сожалению, не все так просто.
Дело в том, что не все сервисы хранят атрибуты, используемые для фильтрации
или сортировки. Например, одним из критериев фильтрации в запросе findOr-
derHistory() служит ключевое слово, которое совпадает с пунктом меню. Только
два сервиса, Order и Kitchen, хранят заказанные позиции меню. Сервисы Delivery
и Accounting этого не делают, поэтому не могут отфильтровать данные по ключе
вому слову. По этой же причине сервисы Kitchen и Delivery не могут сортировать
значения по атрибуту orderCreationDate.
7.2. Применение шаблона CQRS 275
У API-композитора есть два варианта решения этой проблемы. Он может вы
полнить слияние в памяти (рис. 7.7). Для этого он извлекает все заказы клиента из
сервисов Delivery и Accounting, после чего объединяет их с заказами, полученными
из сервисов Order и Kitchen.
Рис. 7.7. При объединении API нельзя эффективно извлечь заказы клиента, поскольку некоторые
провайдеры, такие как сервис Delivery, не хранят атрибуты, используемые для фильтрации
Недостаток этого подхода в том, что API-композитору, возможно, придется из
влекать и объединять большие наборы данных. Это было бы неэффективно.
Есть еще одно решение. API-композитор может извлечь подходящие заказы из
сервисов Order и Kitchen и затем запросить заказы из других сервисов по их ID.
Но это имеет смысл только в том случае, если API этих сервисов поддерживает
массовое извлечение. Запрашивать заказы по отдельности, скорее всего, будет не
эффективно из-за лишнего сетевого трафика.
Такие операции, как f indOrderHistory (), требуют, чтобы API-композитор дубли
ровал функции системы выполнения запросов СУРБД. С одной стороны, это может
сместить нагрузку с менее масштабируемой базы данных на более масштабируемое
приложение. Но с другой — это окажется не так эффективно. К тому же разработчики
должны реализовывать бизнес-возможности, а не систему выполнения запросов.
Чуть позже я покажу, как применять шаблон CQRS и использовать отдельное хра
нилище, которое подходит для эффективной реализации запроса f indOrderHistory ().
Но сначала рассмотрим пример операции, которую непросто реализовать, несмотря
на то что она не выходит за рамки одного сервиса.
276 Глава 7 • Реализация запросов в микросервисной архитектуре
Непростой односервисный запрос findAvailableRestaurants()
Как вы только что видели, реализация запросов, извлекающих данные из разных
сервисов, может создать определенные трудности. Но проблемы способны возник
нуть даже с запросами, локальными для одного сервиса. Вызвать их могут несколько
причин. Во-первых, как вы вскоре узнаете, запрос не должен реализовываться серви
сом, который владеет данными. Во-вторых, определенные запросы могут оказаться
неэффективными с точки зрения базы (или модели) данных сервиса.
Возьмем, к примеру, операцию findAvailableRestaurants(). Она находит ре
стораны, которые могут доставить еду по заданному адресу и в заданное время. В ее
основе лежит геопространственный (основанный на местоположении) поиск ресто
ранов, расположенных на определенном расстоянии от адреса доставки. Это важная
часть процесса заказа, инициируемая модулем пользовательского интерфейса, ото
бражающим доступные рестораны.
Ключевой аспект этого запроса — выполнение эффективного геопространствен
ного поиска. То, как вы реализуете операцию findAvailableRestaurants(), зависит
от возможностей базы данных, которая хранит сведения о ресторанах. Например,
это легко сделать в MongoDB или с помощью геопространственных расширений для
Postgres и MySQL. Эти БД поддерживают геопространственные типы данных, ин
дексы и запросы. При их использовании сервис Restaurant сохраняет информацию
о ресторане в виде записи с атрибутом location. Чтобы найти доступные рестораны,
он применит запрос, оптимизированный благодаря геопространственному индексу
по атрибуту location.
Если приложение FTGO хранит сведения о ресторанах в другой базе данных,
реализация запроса f indAvailableRestaurant () окажется более трудной. Мы долж
ны хранить копию данных о ресторанах в формате, который поддерживает геопро
странственный поиск. Приложение, к примеру, могло бы использовать библиотеку
Geospatial Indexing для DynamoDB (github.com/awslabs/dynamodb-geo), в которой та
блица выступает в роли геопространственного индекса. Как вариант, копию данных
о ресторанах можно хранить в совершенно другой базе данных. Это очень похоже
на ситуацию, когда для текстовых запросов применяется система полнотекстового
поиска.
Сложность репликации данных связана с тем, что их необходимо поддерживать
в актуальном состоянии при изменении оригинала. Как вы вскоре узнаете, проблема
синхронизации реплик решается с помощью CQRS.
Необходимость в разделении ответственности
Еще одна проблема с односервисными запросами связана с тем, что иногда за их
реализацию должен отвечать не тот сервис, который владеет данными. Операция
f indAvailableRestaurants() извлекает данные, принадлежащие сервису Restaurant.
Этот сервис позволяет владельцам ресторанов управлять своими профилями и меню.
Он хранит различные атрибуты, включая название ресторана, его адрес, кулинар
ную направленность, меню и время работы. Сервис владеет данными, поэтому, по
7.2. Применение шаблона CQRS 277
крайней мере на первый взгляд, было бы логично поручить ему реализацию запро
са. Однако принадлежность данных — не единственный фактор, который следует
учитывать.
Вы также должны помнить о необходимости разделения ответственности и не
возлагать на сервисы слишком много обязанностей. Например, основная обязан
ность команды разработки сервиса Restaurant состоит в том, чтобы позволить ад
министрации управлять своим рестораном. Это имеет мало общего с реализацией
интенсивной и критически важной операции. Кроме того, если бы эти разработчики
отвечали за запрос findAvailableRestaurants(), им пришлось бы постоянно беспо
коиться о том, что любое развертываемое ими изменение может нарушить функцию
размещения заказов.
Лучше сделать так, чтобы сервис Restaurant предоставлял данные, а реализацию
запроса findAvailableRestaurants() возложить на другую команду — скорее всего,
это будут разработчики сервиса Order. Как в случае с операцией findOrderHistory(),
если вам нужно поддерживать геопространственный индекс, вы должны хранить
копию данных с отложенной согласованностью. Посмотрим, как этого добиться
с помощью CQRS.
7.2.2. Обзор CQRS
Примеры, описанные в подразделе 7.2.1, продемонстрировали три проблемы, которые
часто встречаются при реализации запросов в микросервисной архитектуре.
□ Объединение API для извлечения данных, разбросанных по разным сервисам,
приводит к затратным малоэффективным операциям JOIN, выполняемым в па
мяти.
□ Сервис, владеющий данными, хранит их в формате или базе данных, которые
не имеют эффективной поддержки нужного запроса.
□ Необходимость разделения ответственности означает, что реализацией запроса
должен заниматься не тот сервис, который владеет данными.
Шаблон CQRS решает все эти проблемы.
CQRS разделяет команды и запросы
CQRS расшифровывается как разделение ответственности командных запросов.
Как следует из названия, этот шаблон предназначен для разделения обязанностей.
На рис. 7.8 показано, как он разделяет хранимую модель данных и использующие ее
модули на две части: команды и запросы. Командные модули и модель данных реа
лизуют операции создания, обновления и удаления (create, update и delete, CUD),
которые соответствуют НТТР-командам POST, PUT и DELETE. Модули запросов
и модель данных реализуют запросы, соответствующие HTTP-команде GET. Сто
рона запросов синхронизирует свою модель данных с моделью данных командной
стороны, подписываясь на события, которые та публикует.
278 Глава 7 • Реализация запросов в микросервисной архитектуре
Рис. 7.8. Сервис справа поддерживает CQRS, а слева — нет. CQRS разделяет сервис на модули
команд и запросов, которые имеют отдельные базы данных
У обеих версий сервиса (с CQRS и без него) есть API, состоящий из различных
CRUD-операций. В сервисе, не основанном на CQRS, эти операции обычно реали
зуются доменной моделью, привязанной к базе данных. Для улучшения произво
дительности некоторые запросы могут миновать доменную модель и обращаться
к базе данных напрямую. Единая хранимая модель данных поддерживает и команды,
и запросы.
В сервисе, основанном на CQRS, доменная модель командной стороны обраба
тывает СRU D-операции и привязана к собственной базе данных. Она может обра
батывать также простые запросы, использующие первичные ключи и не содержащие
операций слияния. Командная сторона публикует события при каждом изменении
своих данных. Для этого может задействоваться фреймворк, такой как Eventuate
Tram, или порождение событий.
За нетривиальные запросы отвечает отдельная модель. Она намного проще по
сравнению с командной стороной, потому что ей не нужно реализовывать бизнес-
правила. Чтобы поддерживать необходимые запросы, эта модель использует подхо
дящую для этого базу данных. Она содержит обработчики, которые подписываются
на доменные события и обновляют базу (-ы) данных. Таких моделей может быть
несколько, по одной для каждого вида запросов.
7.2. Применение шаблона CQRS 279
CQRS и сервисы, предназначенные только для запросов
Помимо использования внутри сервиса, CQRS можно применять для описания за
прашивающих сервисов. API запрашивающего сервиса состоит только из запросов
и не поддерживает командные операции. Для реализации запросов он обращается
к базе данных, которую поддерживает в актуальном состоянии, подписываясь на
события, публикуемые одним или несколькими сервисами. Сервис стороны за
просов — это хороший способ реализовать представление, которое формируется
благодаря подписке на события, генерируемые разными сервисами. Такое представ
ление имеет смысл реализовать отдельно, так как оно не относится ни к одному из
существующих сервисов. Хороший пример — запрашивающий сервис Order History,
который реализует запрос findOrderHistory(). Он подписывается на события, пу
бликуемые несколькими сервисами, включая Order, Delivery и т. д. (рис. 7.9).
Рис. 7.9. Структура запрашивающего сервиса Order History. Он реализует операцию запроса
findOrderHistoryO, обращаясь к базе данных, актуальность которой поддерживается путем подписки
на события, публикуемые другими сервисами
У сервиса Order History есть обработчики, которые подписываются на события,
публикуемые несколькими сервисами, и обновляют базу данных представления Order
History. Реализация этого сервиса подробнее рассматривается в разделе 7.4.
Запрашивающий сервис также хорошо подходит для реализации представления,
которое реплицирует данные, принадлежащие одному сервису, но из-за разделе
ния ответственности не являющиеся его частью. Например, разработчики FTGO
могут определить сервис Available Restaurants, который реализует операцию
findAvailableRestaurants(), описанную ранее. Он подписывается на события,
280 Глава 7 • Реализация запросов в микросервисной архитектуре
публикуемые сервисом Restaurant, и обновляет базу данных с эффективной под
держкой геопространственного поиска.
По большому счету, CQRS — это обобщенная разновидность популярной ме
тодики, которая заключается в том, что СУРБД используется в качестве системы
записей, а поисковая система, такая как Elasticsearch, отвечает за полнотекстовый
поиск. Но есть одно отличие: в CQRS применяется более широкий диапазон баз дан
ных, а не только система полнотекстового поиска. Кроме того, благодаря подписке
на события представления стороны запросов в CQRS обновляются почти в режиме
реального времени.
Взвесим достоинства и недостатки CQRS.
7.2.3. Преимущества CQRS
CQRS имеет как сильные, так и слабые стороны. Начнем с сильных.
□ Возможность эффективной реализации запросов в микросервисной архитектуре.
□ Возможность эффективной реализации разнородных запросов.
□ Возможность выполнения запросов в приложении, основанном на порождении
событий.
□ Улучшенное разделение ответственности.
Возможность эффективной реализации запросов
в микросервисной архитектуре
Одно из преимуществ шаблона CQRS — эффективная реализация запросов, кото
рые извлекают данные из нескольких сервисов. Как упоминалось ранее, запросы,
основанные на объединении API, иногда приводят к неэффективному слиянию
больших наборов данных прямо в памяти. В таких случаях нужно использовать
легкодоступное CQRS-представление, которое заранее объединяет данные из двух
или более сервисов.
Возможность эффективной реализации разнородных запросов
Еще одно преимущество шаблона CQRS состоит в том, что он позволяет приложе
нию или сервису эффективно реализовывать разнородные запросы. Поддержка всех
запросов в рамках одной хранимой модели данных часто трудна, а иногда и просто
невозможна. Некоторые базы данных NoSQL умеют выполнять только очень огра
ниченные запросы. Но даже если у БД есть расширение для поддержки запросов
определенного вида, использование специализированной базы данных часто более
эффективно. Шаблон CQRS позволяет избежать ограничений конкретного храни
лища данных за счет определения одного или нескольких представлений, каждое из
которых эффективно реализует тот или иной запрос.
7.2. Применение шаблона CQRS 281
Возможность выполнения запросов в приложении,
основанном на порождении событий
CQRS позволяет преодолеть основное ограничение порождения событий. Хранили
ще событий поддерживает только запросы по первичному ключу. CQRS устраняет
эту проблему, создавая для агрегатов одно или несколько представлений, поддержи
ваемых в актуальном состоянии. Для этого обработчики подписываются на потоки
событий, публикуемые агрегатами. В итоге приложения, основанные на порождении
событий, неизменно используют CQRS.
Улучшенное разделение ответственности
Еще одной сильной стороной CQRS является разделение ответственности. Домен
ная модель и соответствующая модель данных занимаются либо командами,
либо запросами. Шаблон CQRS предназначает отдельные программные модули
и структуру базы данных для двух разных частей сервиса. Разделение командной
стороны и стороны запросов во многих случаях упрощает код и облегчает его
поддержку.
Более того, благодаря CQRS за реализацию запроса и хранение данных могут
отвечать разные сервисы. Например, ранее я продемонстрировал, что, хотя сервис
Restaurant и владеет данными, к которым обращается findAvailableRestaurants,
такую важную и интенсивную операцию лучше вынести за его пределы. В CQRS,
чтобы поддерживать представление в актуальном состоянии, запрашивающий
сервис подписывается на события, публикуемые другими сервисами, которым при
надлежат сами данные.
7.2.4. Недостатки CQRS
Наряду с преимуществами CQRS имеет и существенные недостатки:
□ более сложную архитектуру;
□ отставание репликации.
Рассмотрим эти проблемы, начав с возрастающей сложности.
Более сложная архитектура
Один из недостатков шаблона CQRS связан с повышением сложности. Разработчи
кам приходится писать запрашивающие сервисы, которые отвечают за обновление
представлений и обращение к ним. Код усложняется также за счет администриро
вания и обслуживания дополнительных хранилищ данных. Более того, приложение
может использовать разные виды баз данных, что добавляет головной боли как раз
работчикам, так и администраторам.
282 Глава 7 • Реализация запросов в микросервисной архитектуре
Отставание репликации
Еще один недостаток CQRS связан с последствиями рассинхронизации между пред
ставлениями для команд и запросов. Как можно было бы ожидать, между публи
кацией события командной стороной, его обработкой запрашивающим сервисом
и обновлением представления проходит некоторое время. Клиентское приложение,
которое обновляет агрегат и сразу же обращается к представлению, может получить
предыдущую версию агрегата. Подобный код часто пишут таким образом, чтобы
пользователь не сталкивался с потенциальной несогласованностью.
Одно из решений заключается в том, чтобы API для команд и запросов предостав
ляли клиентам сведения о версии. Это позволит определить, устарел ли результат
запроса. Клиент может периодически опрашивать представление, пока не получит
актуальную информацию. Чуть позже я объясню, как API сервисов могут предоста
вить клиентам такую возможность.
Скомпилированное мобильное приложение или одностраничный веб-сайт на
JavaScript, которые реализуют пользовательский интерфейс, могут справиться с отста
ванием репликации за счет обновления своей локальной модели в ответ на успешное
выполнение команды без применения запроса. Для обновления модели они могут,
к примеру, использовать данные, возвращенные командой, и надеяться на то, что
к моменту, когда пользователь инициирует запрос, представление успеет синхронизи
роваться. Один из недостатков этого подхода — то, что для обновления своей модели
пользовательский интерфейс, возможно, должен будет дублировать серверный код.
Как видите, у CQRS есть сильные и слабые стороны. Ранее уже упоминалось, что
объединение API следует использовать везде, где это возможно, a CQRS — только
там, где необходимо.
Итак, вы познакомились с преимуществами и недостатками CQRS. Теперь по
смотрим, как проектировать представления с помощью этого шаблона.
7.3. Проектирование CQRS-представлений
Модуль CQRS-представлепия обладает API, состоящим из одного или нескольких
запросов. Для реализации этих запросов CQRS обращается к базе данных, которая
обновляется за счет подписки на события, публикуемые одним или несколькими
сервисами. Этот модуль содержит базу данных представления и три дочерних мо
дуля (рис. 7.10).
Модуль доступа к данным реализует логику обращения к БД. Модули обра
ботчиков событий и API для запросов используют модуль доступа к данным для
обновления и обращения к БД. Модуль обработчиков событий подписывается на
события и обновляет базу данных. Модуль с API для запросов реализует эти API.
При разработке модуля представления необходимо принять несколько важных
решений по поводу архитектуры.
□ Выбрать базу данных и спроектировать ее структуру.
□ При проектировании модуля доступа к данным решить ряд проблем, включая
поддержку идемпотентных и конкурентных обновлений.
7.3. Проектирование CQRS-представлений 283
Рис. 7.10. Структура модуля CQRS-представления. Обработчики событий обновляют базу данных
представления, доступ к которой выполняется через модуль API запросов
□ При реализации нового представления в существующем приложении или измене
нии структуры готового проекта реализовать механизм эффективного построения
(или перестраивания) представлений.
□ Решить, каким образом клиент будет справляться с отставанием репликации,
описанным ранее.
Рассмотрим каждый из этих аспектов.
7.3.1. Выбор хранилища данных для представления
Ключевым архитектурным решением является выбор базы данных и проектирование
ее структуры. Основная задача базы и модели данных заключается в эффективной
реализации запросов модуля представления. Именно характеристики этих запро
сов становятся основным фактором при выборе базы данных. Но БД также должна
эффективно реализовывать операции обновления, выполняемые обработчиками
событий.
Выбор между SQL и NoSQL
Не так давно СУРБД на основе SQL были единственным видом баз данных, ко
торые использовались для всего на свете. Но с ростом популярности Интернета
разные компании начали замечать, что СУРБД больше не отвечают их требованиям
к масштабируемости. Это привело к созданию так называемых NoSQL-хранилищ.
Базы данных NoSQL обычно поддерживают ограниченный набор транзакций и за
просов. В некоторых сценариях они имеют определенные преимущества перед БД
284 Глава 7 • Реализация запросов в микросервисной архитектуре
на основе SQL, включая более гибкую модель данных, а также лучшие производи
тельность и масштабируемость.
Базы данных NoSQL обычно хорошо подходят для CQRS-представлений, ко
торые способны использовать их сильные стороны и игнорировать недостатки.
CQRS-представлению идут на пользу более развитая модель данных и высокая
производительность NoSQL. Ему не страшны ограничения этой технологии, по
скольку оно применяет лишь простые транзакции и выполняет фиксированный
набор запросов.
Несмотря на все сказанное, в некоторых случаях CQRS-представления лучше
реализовывать с помощью баз данных с поддержкой SQL. Современные СУРБД,
запущенные на современном оборудовании, демонстрируют отличную производи
тельность. У разработчиков, администраторов баз данных и DevOps обычно больше
опыта работы с SQL, чем с NoSQL. Как упоминалось ранее, у СУРБД часто есть рас
ширения для нереляционных функций, таких как геопространственные типы данных
и запросы. Кроме того, CQRS-представлению может понадобиться база данных на
основе SQL для поддержки системы отчетов.
Как показано в табл. 7.1, вам есть из чего выбирать. Выбор осложняется тем, что
границы между разными видами баз данных становятся все менее четкими. Напри
мер, сервер MySQL, который является СУРБД, имеет прекрасную поддержку JSON,
в то же время это одна из сильных сторон MongoDB — документно-ориентированной
БД, основанной на этом формате.
Таблица 7.1. Хранилища для представлений на стороне запросов
Если вам нужно Используйте Пример
Поиск JSON-объектов
по первичному ключу
Документное хранилище
наподобие MongoDB либо
DynamoDB или хранилище типа
«ключ — значение» вроде Redis
Реализация истории заказов
посредством хранения документов
MongoDB для каждого клиента
Поиск JSON-объектов
на основе запросов
Документное хранилище
наподобие MongoDB
или DynamoDB
Реализация пользовательского
интерфейса для клиентов
с помощью MongoDB
или DynamoDB
Текстовые запросы Система полнотекстового поиска
вроде Elasticsearch
Реализация текстового поиска
по заказам путем хранения заказов
в виде документов Elasticsearch
Графовые запросы Графовая база данных, такая
как Neo4j
Реализация обнаружения
мошенничества посредством
хранения графа клиентов, заказов
и других данных
Традиционные
SQL-отчеты или В Г
СУРБД Стандартные бизнес-отчеты
и аналитика
1 https://ru.wikipedia.org/wiki/Business_Intelligence.
7.3. Проектирование CQRS-представлений 285
Мы обсудили разные виды баз данных, с помощью которых можно реализовать
CQRS-представление. Теперь поговорим о том, как его эффективно обновлять.
Вспомогательные операции обновления
Помимо эффективной реализации запросов, модель данных представления должна
предоставлять эффективные операции обновления, которые выполняются обра
ботчиками событий. Обычно для обновления или удаления записей в БД представ
ления обработчики используют первичные ключи. Например, чуть позже я опишу
структуру CQRS-представления для запроса findOrderHistory(). Каждый заказ
в нем хранится в виде записи базы данных с orderld в качестве первичного ключа.
При получении события Order Service представление просто обновляет соответ
ствующую запись.
Однако иногда ему придется обновлять или удалять записи, используя экви
валент внешнего ключа. Возьмем, к примеру, обработчики событий Delivery*.
Если между Delivery и Order существует отношение вида «один к одному», то
Delivery.id может совпадать с Order.id. В этом случае обработчики событий
Delivery* могут легко обновить запись о заказе.
Но представьте, что у агрегата Delivery есть собственный первичный ключ
или что Order и Delivery имеют отношение вида «один ко многим». Некоторые
события типа Delivery*, такие как DeliveryCreated, будут содержать orderld, но
другие, наподобие DeliveryPickedllp, — нет. В этом случае обработчику событий
DeliveryPickedUp придется обновлять запись о заказе, используя deliveryld в ка
честве аналога внешнего ключа.
Некоторые виды баз данных имеют эффективную поддержку обновлений на
основе внешнего ключа. Например, при задействовании СУРБД или MongoDB вам
нужно создать индекс для соответствующего столбца. Однако в других NOSQL-
хранилищах выполнить обновления без применения первичных ключей может
оказаться затруднительно. Приложению придется специально поддерживать некую
связь между внешними и первичными ключами, чтобы знать, какую запись следует
обновить. Например, DynamoDB поддерживает обновления и удаления только по
первичному ключу, поэтому приложение, использующее эту БД, сначала должно
запросить ее вторичный индекс (о нем чуть позже), чтобы определить первичные
ключи обновляемых или удаляемых элементов.
7.3.2. Структура модуля доступа к данным
Обработчики событий и модуль API запросов не обращаются к хранилищу данных
напрямую. Вместо этого они задействуют специальный модуль, который состоит
из объекта доступа к данным (DAO) и его вспомогательных классов. У DAO есть
несколько обязанностей. Он реализует операции обновления, инициируемые обра
ботчиками событий, и операции запросов, которые вызываются модулем запросов.
DAO накладывает типы данных, которые применяются в высокоуровневом коде,
286 Глава 7 • Реализация запросов в микросервисной архитектуре
на API БД. Кроме того, он должен поддерживать конкурентные и идемпотентные
обновления.
Рассмотрим все эти аспекты, начиная с конкурентных обновлений.
Поддержка конкурентности
Иногда объект DAO должен уметь справляться с конкурентными обновлениями
одной и той же записи базы данных. Если представление подписывается на события,
публикуемые агрегатами одного типа, никаких проблем с конкурентностью воз
никнуть не может. Дело в том, что события, которые публикуются определенным
экземпляром агрегата, обрабатываются последовательно. Благодаря этому запись,
относящаяся к экземпляру агрегата, не будет обновляться параллельно. Но если
события, на которые подписано представление, публикуются агрегатами разных
типов, существует вероятность того, что несколько обработчиков одновременно
попытаются обновить одну и ту же запись.
Например, обработчики событий Order* и Delivery* могут быть вызваны в один
и тот же момент для одного и того же заказа. В этом случае они одновременно об
ратятся к DAO, чтобы обновить запись базы данных для этого экземпляра Order.
Объект DAO должен быть написан так, чтобы такие ситуации улаживались кор
ректно. Он не должен позволять одному обновлению перезаписывать другое.
Если для реализации обновления объект DAO считывает и затем записывает из
мененную запись, он должен использовать пессимистичное или оптимистичное
блокирование. В следующем разделе вы увидите пример поддержки конкурентных
обновлений за счет изменения записи без ее предварительного считывания.
Идемпотентные обработчики событий
Как упоминалось в главе 3, обработчик может быть вызван больше одного раза
для одного и того же события. Это не составляет проблемы, если обработчик на
стороне запросов идемпотентный. В этом случае результат обработки повторя
ющихся событий будет корректным. Худшее, что может произойти, — это времен
ная рассинхронизация хранилища данных представления. Например, обработчик,
отвечающий за представление Order History, может получить маловероятную по
следовательность событий DeliveryPickedUp, DeliveryDelivered, DeliveryPickedUp
и DeliveryDelivered (рис. 7.11). Допустим, после изначальной доставки событий
DeliveryPickedUp и DeliveryDelivered брокер сообщений столкнулся с сетевыми
проблемами. В результате доставка возобновилась с более раннего момента времени,
что привело к повторной передаче DeliveryPickedUp и DeliveryDelivered.
После того как событие DeliveryPickedUp пройдет обработку во второй раз,
представление Order History будет какое-то время иметь неактуальное состояние
заказа. Это продлится до тех пор, пока не закончится обработка DeliveryDelivered.
Если такое поведение нежелательно, обработчик должен уметь определять и откло
нять повторяющиеся события, как это делают неидемпотентные обработчики.
7.3. Проектирование CQRS-представлений 287
Обработчик событий неидемпотентный, если повторяющиеся события приво
дят к некорректным результатам. Например, обработчик, который инкрементирует
баланс на банковском счету, не поддерживает идемпотентность. Как объяснялось
в главе 3, неидемпотентные обработчики должны обнаруживать и отклонять дубли
каты, записывая идентификаторы уже обработанных событий в хранилище данных
представления.
Рис. 7.11. События DeliveryPickedUp и Delivery Delivered доставляются два раза, из-за чего
в представлении временно рассинхронизируется состояние заказа
Для надежной работы обработчик должен записывать ID событий и обновлять
хранилище данных атомарным образом. То, как это сделать, зависит он исполь
зуемой базы данных. Если хранилище данных представления основано на SQL,
обработчик может вставлять обработанные события в таблицу PROCESSED_EVENTS
в рамках транзакции, обновляющей представление. Но если вы применяете NoSQL-
хранилище с ограниченной транзакционной моделью, обработчик должен сохранять
события внутри записей (например, документа MongoDB или элемента таблицы
DynamoDB), которые он обновляет.
Следует отметить, что обработчику не нужно записывать ID каждого события.
Если, как в случае с Eventuate, события имеют монотонно растущие идентифика
торы, достаточно хранить в каждой записи значение max (event Id), полученное из
заданного экземпляра агрегата. Это справедливо для тех случаев, когда одна запись
соответствует одному экземпляру агрегата. Если записи представляют собой слия
ние событий из разных агрегатов, они должны содержать словарь, связывающий
[aggregate type, aggregate id] c max(eventld).
Например, вскоре вы увидите, что реализация представления Order History на
основе DynamoDB содержит элементы с атрибутами для отслеживания событий,
которые имеют следующий вид:
{...
"Order3949384394-039434903" : "0000015e0c6fcl8f-0242acll00e50002",
”Delivery3949384394-039434903" : ”0000015e0c6fС264-0242ас1100е50002”,
}
288 Глава 7 • Реализация запросов в микросервисной архитектуре
Это представление является объединением событий, публикуемых разны
ми сервисами. Имя каждого атрибута для отслеживания событий выглядит как
"aggregateType""aggregateId", а значение равно eventld. Позже я подробнее объ
ясню, как это работает.
Клиентские приложения могут использовать представления
с отложенной согласованностью
Как упоминалось ранее, одна из проблем CQRS состоит в том, что клиент, который
обновляет командную сторону и затем немедленно выполняет запрос, может не уви
деть собственное обновление. Ввиду неизбежных задержек в инфраструктуре обмена
сообщениями представление является отложенно согласованным.
API модулей команд и запросов позволяют клиенту обнаружить несогласован
ность с помощью следующего подхода. Операция командной стороны возвращает
клиенту токен с идентификатором опубликованного события. Клиент указывает
этот токен в операции запроса. Если представление не обновилось в результате этого
события, операция вернет ошибку. Модуль представления может реализовать этот
механизм, используя систему обнаружения повторяющихся событий.
7.3.3. Добавление и обновление
CQRS-представлений
CQRS-представления добавляются и обновляются на протяжении жизненного цик
ла приложения. Иногда дополнительное представление требуется для поддержки
нового запроса. Временами из-за изменения структуры БД или необходимости
исправить ошибку в коде, который занимается обновлением, представление при
ходится создавать заново.
Добавление и обновление представлений сами по себе довольно просты. Чтобы соз
дать новое представление, нужно разработать модуль стороны запросов, подготовить
хранилище данных и развернуть сервис. Обработчики в модуле стороны запросов
пропускают через себя все события, благодаря чему представление рано или позд
но актуализируется. В обновлении существующих представлений тоже нет ничего
сложного: вам нужно изменить обработчики событий и перестроить набор данных
с нуля. Однако проблема этого подхода в том, что он вряд ли будет работать в ре
альных условиях. Посмотрим, что с ним не так.
Построение CQRS-представлений с помощью
заархивированных событий
Одна из проблем связана с тем, что брокер не может хранить сообщения бесконечно.
Традиционные брокеры, такие как RabbitMQ, удаляют сообщения, обработанные
потребителем. И даже более современные аналоги наподобие Apache Kafka хранят
сообщения на протяжении ограниченного времени, указанного в конфигурации.
Так что представление нельзя построить, считав все необходимые события из бро-
7.4. Реализация CQRS с использованием AWS DynamoDB 289
кера сообщений. Вместо этого приложению нужно прочитать более старые события,
заархивированные, скажем, в AWS S3. Это можно сделать с помощью масштабиру
емой технологии для хранения больших данных, такой как Apache Spark.
Инкрементальное построение CQRS-представлений
Еще одна проблема, связанная с созданием представлений, состоит в том, что для
обработки всех событий требуется все больше времени и ресурсов. В какой-то мо
мент этот процесс становится слишком медленным и затратным. В качестве решения
можно воспользоваться двухэтапным инкрементальным алгоритмом. Первый этап
периодически вычисляет снимок экземпляров каждого агрегата с учетом предыду
щего снимка и событий, произошедших с момента его создания. На втором этапе
с помощью снимков и любых последующих событий создается представление.
7.4. Реализация CQRS с использованием
AWS DynamoDB
Итак, мы обсудили различные архитектурные проблемы, которые необходимо ре
шить с помощью CQRS. Теперь рассмотрим пример. В этом разделе описывается
реализация CQRS-представления для операции f indOrderHistory () с применением
DynamoDB. AWS DynamoDB — это масштабируемая база данных типа NoSQL,
доступная в виде сервиса в облаке Amazon. Ее модель данных состоит из таблиц,
которые содержат элементы, представляющие собой набор иерархических пар
«ключ — значение» (по примеру JSON-объектов). База данных AWS DynamoDB
полностью управляема, поэтому вы можете динамически масштабировать пропуск
ную способность отдельных таблиц в обе стороны.
CQRS-представление для операции findOrderHistory() потребляет события
из нескольких сервисов, поэтому оно реализуется в виде отдельного сервиса Order
View. Этот сервис имеет API, который реализует две операции: f indOrderHistory ()
и f indOrder (). И хотя последнюю можно реализовать с помощью объединения API,
в этом представлении она предоставляется фактически даром. Структура сервиса
Order History показана на рис. 7.12. Он состоит из набора модулей, каждый из ко
торых имеет определенную обязанность, что упрощает разработку и тестирование.
Далее перечислены обязанности модулей.
□ OrderHistoryEventHandlers — подписывается на события, публикуемые различ
ными сервисами, и вызывает OrderHistoryDAO.
□ Модуль API OrderHistoryQuery — реализует конечные точки REST, описанные
ранее.
□ OrderHistoryDataAccess — содержит объект OrderHistoryDAO, который определяет
методы для обновления и обращения к таблице DynamoDB ftgo-order-history,
а также его вспомогательные классы.
□ Таблица DynamoDB ftgo-order-history — хранит заказы.
290 Глава 7 • Реализация запросов в микросервисной архитектуре
Рис. 7.12. Структура OrderHistoryService. Модуль OrderHistoryEventHandlers обновляет
базу данных в ответ на события. Модуль OrderHistoryQuery реализует запросы, обращаясь
к базе данных. Оба они используют модуль OrderHistory Data Access для доступа к БД
Давайте подробнее рассмотрим структуру обработчиков событий, объекта DAO
и таблицы DynamoDB.
7.4.1. Модуль OrderHistoryEventHandlers
Модуль состоит из обработчиков, которые потребляют события и обновляют та
блицу DynamoDB. Как можно видеть в листинге 7.1, эти обработчики представляют
собой простые однострочные методы, которые вызывают OrderHistoryDao с аргумен
тами, сформированными на основе события.
Листинг 7.1. Обработчики событий, которые вызывают OrderHistoryDao
public class OrderHistoryEventHandlers {
private OrderHistoryDao OrderHistoryDao;
public OrderHistoryEventHandlers(OrderHistoryDao OrderHistoryDao) {
this.OrderHistoryDao = OrderHistoryDao;
}
public void handleOrderCreated(DomainEventEnvelope<OrderCreated> dee) {
OrderHistoryDao.addOrder(makeOrder(dee.getAggregateld(), dee.getEvent()),
makeSourceEvent(dee));
7A. Реализация CQRS с использованием AWS DynamoDB 291
}
private Order makeOrder(String orderld, OrderCreatedEvent event) {
}
public void handleDeliveryPickedUp(DomainEventEnvelope<DeliveryPickedUp>
dee) {
orderHistoryDao.notePickedUp(dee.getEvent().getOrderld(),
makeSourceEvent(dee));
}
У каждого обработчика есть параметр типа DomainEventEnvelope, который со
держит само событие и определенные метаданные с его описанием. Например,
метод handleOrderCreated() вызывается для обработки события OrderCreated.
Он вызывает orderHistoryDao.addOrder(), чтобы создать заказ в базе данных.
Точно так же метод handleDeliveryPickedllp() вызывается для обработки события
DeliveryPickedUp. Чтобы обновить состояние заказа в базе данных, он вызывает
orderHistoryDao.notePickedUp().
В обоих случаях применяется вспомогательный метод makeSourceEvent(), ко
торый создает объект SourceEvent, содержащий идентификатор события, а также
тип и ID агрегата, который его сгенерировал. В следующем разделе вы увидите, что
объект OrderHistoryDao использует SourceEvent, чтобы обеспечить идемпотентность
операции обновления.
Теперь взглянем на структуру таблицы DynamoDB, после чего обследуем объект
OrderHistoryDao.
7.4.2. Моделирование данных и проектирование
запросов с помощью DynamoDB
Операции доступа к данным, предоставляемые DynamoDB, как и многими другими
БД типа NoSQL, куда менее гибки по сравнению с теми, которые можно встретить
в СУРБД. В связи с этим вы должны тщательно подходить к выбору модели хране
ния данных. В частности, структура таблиц во многих случаях определяется нуж
ными вам запросами. Вы должны решить несколько архитектурных задач.
□ Проектирование таблицы ftgo-order-history.
□ Определение индекса для запроса f indOrderHistory.
□ Реализация запроса f indOrderHistory.
□ Разбиение результатов запроса на страницы.
□ Обновление заказов.
□ Обнаружение повторяющихся событий.
Далее поговорим о каждой из них.
292 Глава 7 • Реализация запросов в микросервисной архитектуре
Проектирование таблицы ftgo-order-history
Модель хранилища DynamoDB состоит из таблиц, которые содержат элементы
и индексы, предоставляющие альтернативные способы доступа к этим элементам
(об этом чуть позже). Элемент — это набор именных атрибутов. Значением атри
бута может быть скалярная величина, такая как строка, коллекция из нескольких
строк или набор именных атрибутов. И хотя элемент является эквивалентом строки
в СУРБД, он куда более гибок и может хранить целый агрегат.
Эта гибкость DynamoDB позволяет модулю OrderHistoryDataAccess хранить
каждый заказ в виде отдельного элемента в таблице ftgo-order-history. Каждое
поле класса Order накладывается на атрибут элемента (рис. 7.13). Простые поля,
такие как orderCreationTime и status, соответствуют атрибутам с единственным
значением. Поле lineitems привязывается к атрибуту, содержащему список ассо
циативных массивов — по одному на каждую строку. В JSON это можно было бы
назвать массивом объектов.
Рис. 7.13. Предварительная структура таблицы OrderHistory в DynamoDB
Важной частью определения таблицы является ее первичный ключ. В DynamoDB
он используется для вставки, обновления и извлечения элементов. Было бы логично
выбрать в качестве первичного ключа поле orderld. Это позволит сервису Order
History вставлять, обновлять и извлекать заказы по их идентификаторам. Но прежде,
чем окончательно принять это решение, посмотрим, как первичный ключ таблицы
влияет на то, какого рода операции доступа к данным она поддерживает.
Определение индекса для запроса findOrderHistory
Определение этой таблицы поддерживает чтение и запись заказов по первич
ному ключу. Но в нем отсутствует поддержка некоторых запросов, например
findOrderHistory(), который возвращает несколько подходящих заказов, отсорти
рованных по тому, как давно они сделаны. Как вы позже увидите в данном разделе,
это связано с тем, что запросы в DynamoDB задействуют операцию query(), которая
требует, чтобы первичный ключ таблицы состоял из двух скалярных атрибутов.
Первый атрибут — это ключ секции. Он так называется из-за того, что DynamoDB
использует его при масштабировании по оси Z (см. главу 1), чтобы выбрать для эле-
7A. Реализация CQRS с использованием AWS DynamoDB 293
мента секцию хранилища. Вторым атрибутом служит ключ сортировки. Операция
query() возвращает элементы с заданным ключом секции, при этом они должны
соответствовать выражению фильтрации (если таковое имеется), а их ключи сорти
ровки должны относиться к указанному диапазону. Кроме того, ключ сортировки
определяет порядок, в котором возвращаются элементы.
Операция запроса f indOrderHistory () возвращает заказы клиента, отсорти
рованные по давности в порядке возрастания. В связи с этим ей нужен первич
ный ключ, который состоит из ключа секции consumerld и ключа сортировки
orderCreationDate. Однако ключ (consumerld, orderCreationDate) неуникальный,
поэтому применять его в качестве первичного в таблице ftgo-order-history бес
смысленно.
Чтобы решить эту проблему, операция f indOrderHistory () при обращении к та
блице ftgo-order-history должна использовать то, что в DynamoDB называется
вторичным индексом. Он содержит (consumerld, orderCreationDate) в качестве
неуникального ключа. Как и СУРБД, DynamoDB автоматически обновляет свои
индексы при изменении таблицы. Однако в DynamoDB атрибуты индексов могут
не быть ключами, что нехарактерно для реляционных БД. Неключевые атрибуты
улучшают производительность, так как их возвращает запрос, благодаря чему при
ложение может не запрашивать их из таблицы. К тому же, как вы вскоре увидите,
с их помощью можно выполнять фильтрацию. Структура таблицы и упомянутый
ранее индекс показаны на рис. 7.14.
Рис. 7.14. Структура и индекс таблицы OrderHistory
294 Глава 7 • Реализация запросов в микросервисной архитектуре
Данный индекс является частью определения таблицы ftgo-order-history и на
зывается ftgo-order-history-by-consumer-id-and-creation-time. Он состоит из
атрибутов первичного ключа, consumerld и orderCreationTime, а также неключевых
атрибутов, таких как orderld и status.
Индекс ftgo-order-history-by-consumer-id-and-creation-time позволяет объ
екту OrderHistoryDaoDynamoDb эффективно извлекать заказы клиента, отсортиро
ванные по давности в порядке возрастания.
Теперь посмотрим, как извлечь только заказы, соответствующие критериям
фильтра.
Реализация запроса findOrderHistory
У операции запроса findOrderHistory() есть параметр filter, который опреде
ляет критерии поиска. Одним из критериев фильтрации является максимальная
давность возвращаемых заказов. Это легко реализовать, поскольку в DynamoDB
ключевое условное выражение запроса поддерживает ограничение по диапазону
для ключа сортировки. Еще один критерий относится к неключевым атрибутам
и может быть реализован с помощью выражения фильтрации булева типа. Опера
ция запроса DynamoDB возвращает только те элементы, которые удовлетворяют
выражению фильтрации. Например, чтобы найти заказы с состоянием CANCELLED,
объект OrderHistoryDaoDynamoDb может использовать выражение orderstatus =
:orderstatus, где :orderstatus — подставляемый параметр.
Реализация критериев фильтрации по ключевым словам не так проста. Она вы
бирает заказы, у которых название ресторана или пункты меню совпадают с одним
из заданных ключевых слов. Чтобы выполнить поиск по ключевым словам, объект
OrderHistoryDaoDynamoDb разбивает названия ресторанов и пункты меню на тер
мины, которые хранятся в массиве внутри атрибута keywords. Для поиска заказов,
соответствующих ключевым словам, он задействует функцию contains(), например
contains(keywords, :keywordl) OR contains(keywords, :keyword2), где :keywordl
и : keyword2 — подставляемые параметры для заданных ключевых слов.
Разбиение результатов запроса на страницы
У некоторых клиентов будет много заказов. Поэтому логично сделать так, чтобы
операция f indOrderHistory () возвращала их постранично. В DynamoDB операции
запросов имеют параметр pageSize, который определяет максимальное количество
возвращаемых элементов. Если элементов оказывается больше, результат запроса
будет содержать ненулевой атрибут LastEvaluatedKey. Чтобы извлечь следующую
страницу, объект DAO может указать параметру запроса exclusiveStartKey значение
LastEvaluatedKey.
Как видите, DynamoDB не поддерживает секционное разбиение на страницы.
Следовательно, сервис Order History возвращает своему клиенту непрозрачный
токен, с помощью которого тот может запросить следующую страницу с резуль
татами.
7.4. Реализация CQRS с использованием AWS DynamoDB 295
Обновление заказов
DynamoDB поддерживает операции Putltem() и Updateltem() для добавления и обнов-
ления элементов соответственно. Put Item () создает или заменяет по первичному ключу
целый элемент. Теоретически объект OrderHistoryDaoDynamoDb мог бы использовать эту
операцию для обновления заказов. Но применение данного подхода затрудняет то,
что требуется обеспечить корректную обработку одновременных обновлений.
Представьте, к примеру, что два обработчика событий одновременно пы
таются обновить один и тот же элемент. Каждый из них обращается к OrderHi
storyDaoDynamoDb, чтобы загрузить этот элемент из DynamoDB, изменить его в па
мяти и обновить запись, взяв Putltem(). Один обработчик событий потенциально
может перезаписать изменения, внесенные другим. Чтобы предотвратить потерю
изменений, OrderHistoryDaoDynamoDb может воспользоваться механизмом опти
мистичного блокирования из состава DynamoDB. Однако проще и эффективнее
применить операцию Updateltem().
Операция Updateitem О обновляет отдельные атрибуты элемента или создает его
целиком, если это необходимо. Ее использование имеет смысл, поскольку разные обра
ботчики изменяют разные атрибуты заказа. К тому же эта операция более эффективна,
потому что не требует предварительного извлечения заказа из таблицы.
Как упоминалось ранее, одной из проблем обновления базы данных в ответ на
события является обнаружение дубликатов. Посмотрим, как это сделать с помощью
DynamoDB.
Обнаружение повторяющихся событий
Все обработчики событий в сервисе Order History идемпотентные. Каждый из них
устанавливает один или несколько атрибутов для элемента Order. Таким образом,
сервис Order History может просто игнорировать проблему повторяющихся собы
тий. Однако в этом случае элемент Order будет периодически устаревать. Дело в том,
что обработчик, который принимает повторяющееся событие, назначит атрибутам
элемента Order предыдущие значения. То есть элемент окажется неактуальным, пока
не наступят следующие события.
Как упоминалось ранее, чтобы предотвратить устаревание данных, повторя
ющиеся события можно обнаруживать и отклонять. Для этого объект OrderHi
storyDaoDynamoDb может записывать в элементы события, которые инициировали их
обновление. Затем он может воспользоваться механизмом условного обновления из
операции Updateltem(), чтобы изменять элементы, только если событие не является
дубликатом.
Условное обновление производится только при выполнении условного вы
ражения. Это выражение проверяет существование атрибута или наличие в нем
определенного значения. DAO-объект OrderHistoryDaoDynamoDb может отслеживать
события, полученные из каждого экземпляра агрегата, для этого он использует
атрибут "aggregateType””aggregateId", чье значение равно наивысшему ID приня
того события. Если атрибут существует и его значение меньше или равно этому ID,
296 Глава 7 • Реализация запросов в микросервисной архитектуре
событие является дубликатом. OrderHistoryDaoDynamoDb применяет следующее
условное выражение:
attribute_not_exists("aggregateType""aggregateId")
OR "aggregateType""aggregateId" < :eventld
Условное выражение позволяет обновить элемент, только если атрибута не су
ществует или eventld больше, чем ID последнего обработанного события.
Представьте, к примеру, что обработчик получает из агрегата Delivery с ID
3949384394-039434903 событие Deli very Pickup, чей идентификатор равен 123323-343434.
Отслеживающий атрибут называется Delivery3949384394-039434903. Обработчик
должен рассматривать событие в качестве дубликата, если значение этого атрибута
больше или равно 123323-343434. Операция query(), вызываемая обработчиком со
бытий, обновляет элемент Order с помощью условного выражения:
attribute_not_exists(Delivery3949384394-039434903)
OR Delivery3949384394-039434903 < :eventld
Вы познакомились с моделью данных и структурой запросов DynamoDB. Теперь
рассмотрим класс OrderHistoryDaoDynamoDb, который обновляет таблицу ftgo-order-
history и запрашивает из нее данные.
7.4.3. Класс OrderHistoryDaoDynamoDb
Класс OrderHistoryDaoDynamoDb реализует методы для чтения и записи эле
ментов в таблице ftgo-order-history. Его операции обновления вызываются
из OrderHistoryEventHandlers, а запросы инициируются API OrderHistoryQuery.
Рассмотрим примеры некоторых методов, начиная с addOrder().
Метод addOrder()
Метод addOrder(), представленный в листинге 7.2, добавляет заказ в таблицу ftgo-
order-history. У него есть два параметра — order и sourceEvent. Параметр ordei—
это заказ, который нужно добавить, он извлекается из события OrderCreated. Параметр
sourceEvent содержит eventld, а также тип и идентификатор агрегата, который
сгенерировал событие. Он используется для реализации условных обновлений.
Листинг 7.2. Метод addOrder() добавляет или обновляет заказ
public class OrderHistoryDaoDynamoDb ... Первичный ключ
обновляемого элемента Order
Выражение, Г* * wEthUpdateExpression("SET orderstatus
обновляющее
атрибуты
^Override
public boolean addOrder(Order order, Optional<SourceEvent> eventsource) {
UpdateltemSpec spec = new UpdateltemSpecQ
.withPrimaryKey("orderId", order.getOrderldQ) ◄-------------------
i = :orderstatus, " +
"creationDate = :cd, consumerld = :consumerld, lineitems =" +
" :lineitems, keywords = :keywords, restaurantName = " +
":restaurantName")
7A. Реализация CQRS с использованием AWS DynamoDB 297
подставляемых
параметров
в выражении
обновления
.withValueMap(new Maps()
.add(”:orderstatus"> order.getStatus().toString())
.add(":cd", order.getCreationDate().getMillis())
.add(":consumerld", order.getConsumerld())
.add(":lineitems", mapLineltems(order.getLineitems()))
,add(":keywords", mapKeywords(order))
.add(":restaurantName"> order.getRestaurantName())
.map())
.withReturnValues(Returnvalue.NONE);
return idempotentupdate(spec, eventsource);
}
Метод addOrder() создает объект UpdateSpec, который входит в состав AWS SDK
и описывает операцию обновления. После этого он вызывает вспомогательный ме
тод idempotentUpdate(), который добавляет условное выражение, предохраняющее
от дубликатов, и выполняет обновление.
Метод notePickedUp()
Метод notePickedUp(), показанный в листинге 7.3, вызывается обработчиком со
бытий типа DeliveryPickedUp. Он меняет состояние deliverystatus элемента Order
на PICKED_UP.
Листинг 7.3. Метод notePickedllp() меняет состояние заказа на PICKED_UP
public class OrderHistoryDaoDynamoDb ...
^Override
public void notePickedUp(String orderld, Optional<SourceEvent> eventsource) {
UpdateltemSpec spec = new UpdateltemSpec()
.withPrimaryKey("orderId", orderld)
.withUpdateExpression("SET #deliveryStatus = rdeliveryStatus")
.withNameMap(Collections.singletonMap(”#deliveryStatus",
DELIVERY_STATUS_FIELD))
.withValueMap(Collections.singletonMap(":deliverystatus",
Deliverystatus.PICKEDJJP.toString()))
.withReturnValues(Returnvalue.NONE);
idempotentUpdate(spec, eventsource);
}
Этот метод похож на addOrder(). Он создает объект UpdateltemSpec и делает вы
зов idempotentUpdate(). О последнем речь пойдет далее.
Метод idempotentUpdate()
В листинге 7.4 показан метод idempotentUpdate(), который обновляет элемент после
возможного добавления условного выражения в объект UpdateltemSpec, предохра
няющий от повторяющихся обновлений.
298 Глава 7 • Реализация запросов в микросервисной архитектуре
Листинг 7.4. Метод idempotentUpdate() игнорирует повторяющиеся события
public class OrderHistoryDaoDynamoDb ...
private boolean idempotentUpdate(UpdateltemSpec spec, Optional<SourceEvent>
eventsource) {
try {
table.updateItem(eventSource.map(es -> es.addDuplicateDetection(spec))
.orElse(spec));
return true;
} catch (ConditionalCheckFailedException e) {
// Ничего не делаем
return false;
}
}
Если указать параметр SourceEvent, метод idempotentUpdate() сделает вызов
SourceEvent .addDuplicateDetection(), чтобы добавить в UpdateltemSpec условное
выражение, описанное ранее. Метод idempotentllpdate() перехватывает и игнори
рует исключение ConditionalCheckFailedException, которое генерируется вызовом
updateltem() в случае, если событие является дубликатом.
Просмотрев код, который обновляет таблицу, можем перейти к методу запроса.
Метод findOrderHistory()
Метод f indOrderHistory(), представленный в листинге 7.5, извлекает заказы клиен
та. Для этого он обращается к таблице ftgo-order-history по вторичному индексу
ftgo-order-history-by-consumer-id-and-creation-time. У него есть два параметра:
consumerld, который идентифицирует клиента, и filter, определяющий поисковые
критерии. На основе своих параметров этот метод формирует объект QuerySpec,
который, как и UpdateSpec, входит в состав AWS SDK. Затем он делает запрос по
индексу и преобразует возвращенные элементы в объект OrderHistory.
Листинг 7.5. Метод findOrderHistoryO извлекает подходящие заказы клиента
public class OrderHistoryDaoDynamoDb ...
^Override
public OrderHistory findOrderHistory(String consumerld, OrderHistoryFilter
filter) {
Запрос должен вернуть заказы
по возрастанию давностиQuerySpec spec = new QuerySpec()
.withScanlndexForward(false)
.withHashKey("consumerld", consumerld)
.withRangeKeyCondition(new RangeKeyCondition("creationDate") <
.gt(filter.getSince().getMillis()));
Максимальная давность
filter. getStartKeyToken() .if Present (token -> возвращаемых заказов
spec.withExclusiveStartKey(toStartingPrimaryKey(token)));
Резюме 299
Map<String, Object> valuesMap = new HashMapoQ;
String filterExpression = Expressions.and( <
keywordFilterExpression(valuesMap, filter.getKeywords()),
statusFilterExpression(valuesMap, filter.getStatus()));
if (1 valuesMap. is EmptyQ)
spec.withValueMap(valuesMap);
Формируем фильтрующее выражение
и словарь для подстановочных
параметров на основе OrderHistoryFilter
Ограничиваем число
результатов, если
вызывающая сторона
указала размер страницы
if (StringUtils.isNotBlank(filterExpression)) {
spec.withFilterExpression(filterExpression);
}
filter.getPageSize().ifPresent(spec:iwithMaxResultSize); <
ItemCollection<QueryOutcome> result = index.query(spec);
return new OrderHistory(
Streamsupport.stream(result.spliterator(), false)
.map(this::toOrder) <
.collect(toList()),
Optional.ofNullable(result
Создаем Order из элемента,
возвращенного запросом
.getLastLowLevelResult()
.getQueryResult().getLastEvaluatedKey())
.map(this::toStartKeyToken));
После построения объекта QuerySpec этот метод выполняет запрос и создает
экземпляр OrderHistory со списком заказов на основе возвращенных элементов.
Метод findOrderHistory() реализует разбиение на страницы путем сериали
зации значения, полученного из getLastEvaluatedKey(), в токен формата JSON.
Если клиент укажет в OrderHistoryFilter начальный токен, findOrderHistory()
его сериализует и вызовет withExclusiveStartKey(), чтобы установить начальный
ключ.
Как видите, в ходе реализации CQRS-представления необходимо решить це
лый ряд вопросов, включая выбор БД, проектирование модели данных, которая
эффективно реализует обновления и запросы, обработку конкурентных обновле
ний и способность справляться с повторяющимися обновлениями. Единственной
сложной частью кода является объект DAO, поскольку он должен иметь корректную
поддержку конкурентности и обеспечивать идемпотентность обновлений.
Резюме
□ Реализация запросов, извлекающих данные из разных сервисов, сопровождается
определенными затруднениями, так как данные каждого сервиса приватные.
□ Для реализации такого рода запросов можно использовать одну из методик:
объединение API или разделение ответственности командных запросов (CQRS).
300 Глава 7 • Реализация запросов в микросервисной архитектуре
□ Шаблон объединения API собирает данные от разных сервисов. Это самый про
стой способ реализации запросов. Его следует применять везде, где это возможно.
□ Ограничение шаблона объединения API связано с тем, что некоторые сложные
запросы требуют неэффективного слияния в памяти больших наборов данных.
□ Шаблон CQRS, реализующий запросы с помощью БД представлений, более
мощный, но его не так просто создать.
□ Модуль представлений CQRS должен поддерживать конкурентные обновления,
а также обнаружение и отклонение повторяющихся событий.
□ CQRS способствует разделению ответственности за счет того, что сервисы могут
реализовывать запросы, которые возвращают данные других сервисов.
□ Клиенты должны быть готовы к отложенной согласованности CQRS-пред-
ставлений.
Шаблоны внешних API
У FTGO, как и у многих других приложений, есть REST API. В число его клиентов
входят мобильные приложения, код на JavaScript, работающий в браузере, и про
граммы, разработанные партнерами. В такой монолитной архитектуре API, видимый
снаружи, и сам монолитный. Но после того, как команда FTGO начала развертывать
микросервисы, единого API больше нет, поскольку у каждого сервиса теперь свой
API. Мэри и ее коллеги должны решить, какого рода API приложение FTGO долж
но сделать доступным для своих клиентов. Например, должны ли клиенты знать
о существовании сервисов и обращаться к ним напрямую?
Проектирование внешнего API приложения усложняется разнообразием его
клиентов. Обычно разным клиентам нужны различные данные. Настольный поль
зовательский веб-интерфейс, как правило, должен выводить намного больше инфор
мации, чем мобильное приложение. Кроме того, для обращения к сервисам разные
клиенты могут пользоваться разными сетями. Например, клиент в пределах бранд
мауэра работает по высокоскоростной локальной сети, тогда как внешние клиенты
302 Глава 8 • Шаблоны внешних API
применяют Интернет или мобильную сеть с более низкой производительностью.
В итоге, как вы сами увидите, наличие единого универсального API часто оказыва
ется нецелесообразным.
Я начну эту главу с описания различных проблем, связанных с проектированием
внешних API. Затем опишу соответствующие шаблоны проектирования. Мы рас
смотрим концепцию API-шлюза и шаблон BFF. После этого я покажу, как спроек
тировать и реализовать API-шлюз. Вам будут представлены доступные варианты,
включая готовые продукты, а также фреймворки для разработки собственной
реализации. Я опишу архитектуру и реализацию API-шлюза, построенного на
основе фреймворка Spring Cloud Gateway. Также вы узнаете, как создать API-шлюз
с помощью фреймворка GraphQL, предоставляющего язык запросов, основанный
на графах.
8.1. Проблемы с проектированием внешних API
Чтобы исследовать проблемы, связанные с API, рассмотрим приложение FTGO.
Его сервисами пользуется целый ряд клиентов четырех типов (рис. 8.1):
□ веб-приложения, реализующие браузерные пользовательские интерфейсы для
заказчиков, ресторанов и администраторов. Последний является внутренним;
□ JavaScript-приложение, работающее в браузере;
□ мобильные приложения — одно для заказчиков, второе для курьеров;
□ приложения, написанные сторонними разработчиками.
Веб-приложения работают в пределах брандмауэра, поэтому обращаются к сер
висам по высокоскоростной локальной сети с низкой латентностью. Другие клиенты
находятся снаружи, поэтому их запросы к сервисам передаются по Интернету или
мобильной сети, обладающим меньшей скоростью и более высокой латентностью.
Один из вариантов проектирования API состоит в том, что клиенты обращают
ся к сервисам напрямую. На первый взгляд это звучит довольно просто — в конце
концов, именно так клиенты вызывают API в монолитных приложениях. Но этот
подход редко применяется в микросервисной архитектуре из-за следующих недо
статков.
□ Для извлечения нужных данных с помощью мелко раздробленных API клиентам
приходится выполнять несколько запросов. Это неэффективно, к тому же клиен
ты могут получить отрицательный опыт взаимодействия с сервисом.
□ Недостаточная инкапсуляция, связанная с тем, что клиенты знают о каждом
сервисе и его API, затрудняет внесение изменений в архитектуру и API.
□ Сервисы могут задействовать механизмы IPC, которые нецелесообразно или
неудобно использовать на клиентской стороне, особенно клиентам за пределами
брандмауэра.
Чтобы узнать больше об этих недостатках, посмотрим, как мобильное потреби
тельское приложение FTGO извлекает данные из сервисов.
8.1. Проблемы с проектированием внешних API 303
Рис. 8.1. Сервисы приложения FTGO и их клиенты. Есть несколько видов клиентов. Одни работают
в пределах брандмауэра, другие — нет. Те, что находятся снаружи, обращаются к сервисам
по Интернету или мобильной сети с относительно низкой скоростью. Внутренние клиенты
применяют высокоскоростную локальную сеть
8.1.1. Проблемы проектирования API
для мобильного клиента FTGO
Для размещения заказов и управления ими потребители используют мобильный
клиент FTGO. Представьте, что в ходе его разработки вам нужно написать пред
ставление View Order для отображения заказа. Как говорилось в главе 7, это пред
ставление выводит общую информацию, такую как состояние заказа с точки зрения
пользователя и ресторана, а также состояние платежа, местоположение курьера
и предположительное время доставки, если она уже в пути.
У API монолитной FTGO есть конечная точка, которая возвращает подробности
о заказе. Чтобы извлечь нужную информацию, мобильному клиенту достаточно
сделать один запрос. Но в микросервисной архитектуре детали заказа разбросаны
по нескольким сервисам, включая следующие:
□ Order — основная информация, включая подробности и статус;
□ Kitchen — статус заказа с точки зрения ресторана и то, когда он должен быть
готов к доставке;
304 Глава 8 • Шаблоны внешних API
□ Delivery — состояние доставки заказа, предполагаемая информация о доставке
и местоположение курьера в настоящее время;
□ Accounting — состояние оплаты заказа.
Если мобильный клиент обращается к сервисам напрямую, для извлечения дан
ных ему придется сделать несколько запросов (рис. 8.2).
Рис. 8.2. Чтобы извлечь подробности о заказе в монолитной версии FTGO, клиенту достаточно
одного запроса. Но для извлечения той же информации в микросервисной архитектуре
потребуется несколько запросов
8.1. Проблемы с проектированием внешних API 305
В этой архитектуре мобильное приложение играет роль API-композитора.
Оно обращается к нескольким сервисам и объединяет результаты. Этот подход
может показаться разумным, но у него есть несколько проблем.
Отрицательный опыт взаимодействия из-за того, что клиент
выполняет несколько запросов
Первая проблема состоит в том, что мобильному приложению иногда придется
выполнять несколько запросов для извлечения данных, которые нужно отобразить
для пользователя. Слишком интенсивное взаимодействие между приложением
и сервисами может плохо сказаться на отзывчивости приложения, особенно если
оно проходит по Интернету или мобильной сети. Интернет обладает куда меньшей
пропускной способностью и более высокой латентностью по сравнению с локаль
ной сетью, а мобильные сети в этом отношении еще хуже. В обоих случаях уровень
латентности обычно различается в 100 раз.
Повышенная латентность при извлечении деталей заказа не обязательно вызыва
ет проблемы, так как мобильное приложение минимизирует задержки за счет конку
рентного выполнения запросов. Общее время оказывается не больше, чем в случае
с одним запросом. Но иногда запросы приходится выполнять последовательно, что
уменьшает удобство использования клиента.
Однако негативный опыт взаимодействия из-за сетевых задержек не единствен
ная проблема, присущая интенсивному обращению к API. Мобильным разработ
чикам иногда приходится писать довольно сложный код для объединения API.
Это отвлекает их от создания удобных пользовательских интерфейсов — их основ
ной задачи. Кроме того, на каждый сетевой запрос затрачивается электроэнергия,
поэтому интенсивная работа с API быстрее сажает аккумулятор мобильного
устройства.
Недостаточная инкапсуляция требует синхронного обновления
кода на серверной и клиентской сторонах
Еще один недостаток того, что мобильное приложение напрямую обращается к сер
висам, связано с недостаточной инкапсуляцией. По мере развития приложения раз
работчики сервисов иногда меняют API, нарушая работу существующих клиентов.
Изменения могут касаться даже декомпозиции системы. Разработчики могут доба
вить новые сервисы или разделить/слить уже имеющиеся. И если сведения о сер
висах вшиты в мобильное приложение, изменить их API может оказаться непросто.
В отличие от обновления серверных систем, выкатывание новой версии мобиль
ного приложения может занять часы или даже дни. Такие компании, как Apple или
Google, должны одобрить ваше обновление и сделать его доступным для загрузки.
При этом не факт, что пользователи загрузят его сразу (или вообще когда-нибудь),
и вряд ли стоит заставлять их это делать. Открытие API для мобильных устройств
создает существенные преграды для его последующего развития.
306 Глава 8 Шаблоны внешних API
Сервисы могут использовать механизмы IPC,
плохо совместимые с клиентами
У непосредственного взаимодействия между мобильным приложением и сервиса-
ми есть еще один недостаток: иногда сервисы используют протоколы, с которыми
клиентам сложно работать. Клиентские приложения, находящиеся за пределами
брандмауэра, обычно применяют такие протоколы, как HTTP и WebSockets. Но как
упоминалось в главе 3, у разработчиков сервисов есть большой выбор протоколов,
помимо HTTP. Одни используют gRPC, другие — обмен сообщениями на основе
AMQP. Такого рода протоколы хорошо работают внутри, но их совместимость
с мобильными клиентами может быть оказаться под вопросом. Некоторые из них
даже не умеют обходить брандмауэр.
8.1.2. Проблемы с проектированием API
для клиентов другого рода
Я выбрал мобильные клиенты, потому что они отлично демонстрируют недостатки
прямого доступа к сервисам. Но эти проблемы не ограничены лишь мобильными
устройствами. Они распространяются и на клиенты другого рода, особенно те, что
находятся за пределами брандмауэра. Как описывалось ранее, сервисы приложе
ния FTGO потребляются веб-сайтами, программами, основанными на JavaScript,
и сторонними системами. Рассмотрим, какие трудности могут возникнуть при про
ектировании API для таких клиентов.
Проблемы проектирования API для веб-приложений
Традиционные серверные веб-приложения, которые обрабатывают НТТР-запросы
браузера и возвращают HTML-страницы, находятся в пределах брандмауэра и об
ращаются к сервисам по локальной сети. Таким образом, при объединении API
пропускная способность и латентность сети не оказываются проблемой. Кроме того,
для работы с сервисами веб-приложения могут использовать протоколы, плохо
совместимые с веб-технологиями. Их разработчики часто тесно взаимодействуют
с командами, отвечающими за серверную сторону, так как все они — части одной
организации, поэтому веб-приложение можно легко обновлять при изменениях
в сервисах. Из-за этого веб-приложениям стоит работать с сервисами напрямую.
Проблемы проектирования API
для браузерных JavaScript-приложений
Современные приложения содержат некоторое количество кода на языке JavaScript.
Даже если HTML в основном генерируется серверным веб-приложением, для до
ступа к сервисам часто используется код на JavaScript, выполняющийся в браузере.
Например, все веб-приложения в проекте FTGO (Consumer, Restaurant и Admin) со
держат JavaScript-код, который обращается к серверной стороне. Веб-приложение
Consumer, например, динамически обновляет страницу Order Details, вызывая API
сервисов с помощью JavaScript.
8.2. Шаблон «API-шлюз» 307
С одной стороны, JavaScript-приложения, запускаемые в браузере, можно легко
обновлять при изменениях в API. Но с другой — если они обращаются к сервисам
по Интернету, у них могут наблюдаться те же проблемы с сетевой латентностью, что
и у мобильных клиентов. Что еще хуже, браузерные пользовательские интерфейсы,
особенно предназначенные для настольных компьютеров, обычно сложнее мо
бильных приложений и должны объединять большее количество сервисов. Вполне
вероятно, что приложения Consumer и Restaurant, которые вызывают сервисы по
Интернету, не смогут эффективно объединять разные API.
Проектирование API для сторонних приложений
Компания FTGO, как и многие другие организации, предоставляет для сторонних раз
работчиков API, с помощью которого те могут создавать приложения для размещения
и администрирования заказов. Эти сторонние программы работают через Интернет,
поэтому объединение API, скорее всего, будет неэффективным. Но это относитель
но небольшая проблема по сравнению с трудностями при проектировании API для
сторонних клиентов. Ведь сторонним разработчикам нужен стабильный интерфейс.
Очень немногие организации способны заставить сторонних разработчиков
обновиться до новой версии API. Если API приложения становится нестабильным,
сторонние клиенты могут перестать его поддерживать и перейти к конкурентам.
В связи с этим вы должны тщательно продумывать развитие API, которые исполь
зуются другими организациями. Обычно для этого приходится долго поддерживать
старые версии или даже сохранять их навсегда.
Это требование ложится на организацию большим бременем. Возлагать ответ
ственность за долгосрочную поддержку обратной совместимости на разработчиков
сервисов было бы непрактично. Вместо того чтобы открывать сторонним клиентам
прямой доступ к сервисам, организация должна иметь отдельный публичный API,
который разрабатывает отдельная команда. Как вы позже увидите, публичные API
реализуются архитектурным компонентом, известным как API-шлюз. Давайте по
смотрим, как он работает.
8.2. Шаблон «API-шлюз»
Как вы только что узнали, прямой доступ к сервисам чреват целым рядом проблем.
Зачастую выполнять объединение API по Интернету на стороне клиента непрак
тично. Недостаточная инкапсуляция затрудняет изменение декомпозиции сервисов
и обновление API. Иногда сервисы применяют коммуникационные протоколы, ко
торые плохо работают за пределами брандмауэра. Учитывая все это, гораздо лучше
будет использовать API-шлюз.
308 Глава 8 • Шаблоны внешних API
API-шлюз — это сервис, который служит точкой входа в приложение из внешнего
мира. Он отвечает за маршрутизацию запросов, объединение API и другие возмож
ности, например аутентификацию. В данном разделе мы рассмотрим его со всеми
преимуществами и недостатками. Вы познакомитесь с проблемами проектирования,
которые необходимо решить при разработке API-шлюза.
8.2.1. Обзор шаблона «API-шлюз»
В разделе 8.1.1 описывались недостатки подхода, при котором клиенты (например,
мобильное приложение FTGO) выполняют множество запросов, чтобы отобразить
информацию для пользователя. Намного лучше было бы сделать так, чтобы клиент
делал один запрос к API-шлюзу — сервису, который служит единой точкой входа
для API-запросов к приложению из-за пределов брандмауэра. Это похоже на шаблон
объектно-ориентированного проектирования «Фасад» в том смысле, что API-шлюз
инкапсулирует внутреннюю архитектуру приложения и предоставляет API его
клиентам. Он может иметь и другие функции, такие как аутентификация, монито
ринг и ограничение частоты запросов. Отношения между клиентами, API-шлюзом
и сервисами показаны на рис. 8.3.
Рис. 8.3. API-шлюз — это единая точка входа в приложение для API-вызовов
из-за пределов брандмауэра
8.2. Шаблон «API-шлюз» 309
API-шлюз отвечает за маршрутизацию запросов, объединение API и преоб
разование протоколов. Все запросы, выполняемые внешними клиентами, сначала
поступают на API-шлюз, который направляет их подходящим сервисам. Для обра
ботки других запросов API-шлюз использует объединение API, обращаясь к разным
сервисам и агрегируя полученные результаты. Он может также налаживать связь
между клиентскими протоколами, такими как HTTP и WebSockets, и внутренними
протоколами сервисов, плохо совместимыми с клиентами.
Маршрутизация запросов
Одна из ключевых функций API-шлюза — маршрутизация запросов. Некоторые
API-вызовы реализуются путем направления запросов подходящим сервисам.
Когда API-шлюз получает запрос, он сверяется с картой маршрутизации, которая
определяет, какому сервису следует направить этот запрос. Например, карта маршру
тизации может привязывать HTTP-метод и путь к URL-адресу сервиса. Эта функция
идентична возможностям обратного прокси, которые предоставляют такие серверы,
KaKNGINX.
Объединение API
API-шлюз обычно не ограничивается обратным проксированием. Он может также
реализовывать некоторые API-операции, используя объединение API. Например,
в FTGO он таким образом выполняет операцию Get Order Details. Мобильное при
ложение делает один запрос к API-шлюзу, а тот извлекает подробности о заказе из
нескольких сервисов (рис. 8.4).
API-шлюз приложения FTGO предоставляет обобщенный API, который по
зволяет клиентам извлекать нужные им данные с помощью лишь одного запроса.
Например, мобильный клиент делает единственный запрос getOrderDetails().
Преобразование протоколов
API-шлюз способен также преобразовывать протоколы. Для внешних клиентов он
может предоставлять RESTful API, хотя внутри сервисы приложения используют
сочетание разных протоколов, включая REST и gRPC. При необходимости реали
зация некоторых API-операций налаживает связь между внешним RESTful API
и внутренними интерфейсами на основе gRPC.
API-шлюз предоставляет каждому клиенту отдельный API
API-шлюз мог бы предоставлять единый универсальный API. Но проблема такого
подхода состоит в том, что у разных клиентов разные требования. Например, сто
роннему приложению может понадобиться, чтобы API-операция Get Order Details
возвращала все подробности о заказе, тогда как мобильному клиенту нужна лишь
часть из них. В качестве решения можно позволить клиентам указывать в запросе,
какие поля и сопутствующие объекты им следует вернуть. Это подходит для публич
ных API, которые должные обслуживать широкий спектр сторонних приложений,
притом что клиенты часто лишены необходимого им контроля.
310 Глава 8 • Шаблоны внешних API
Рис. 8.4. API-шлюз часто выполняет объединение API, что позволяет таким клиентам,
как мобильные устройства, эффективно извлекать данные с помощью одного API-запроса
Вместо этого лучше сделать так, чтобы API-шлюз выделял каждому клиенту
отдельный API. Например, API-шлюз FTGO может предоставить мобильному
клиенту API, специально «заточенный» под его требования. И даже поддерживать
разные версии API для Android и iPhone. Отдельный публичный API будет предо
ставлен также сторонним разработчикам. Позже вы познакомитесь с шаблоном BFF,
который выводит эту концепцию на новый уровень, определяя разные API-шлюзы
для каждого клиента.
8.2. Шаблон «API-шлюз» 311
Реализация граничных функций
Основными обязанностями API-шлюза являются маршрутизация и объединение
API, но он способен взять на себя также реализацию граничных функций. Граничная
функция, как понятно из названия, — это операция обработки запросов на границе
приложения. Можно привести следующие примеры:
□ аутентификация — проверка подлинности клиента, который делает запрос;
□ авторизация — проверка того, что клиенту позволено выполнять определенную
операцию;
□ ограничение частоты запросов — контроль над тем, сколько запросов в секунду
могут выполнять определенный клиент и/или все клиенты вместе;
□ кэширование — кэширование ответов для снижения количества запросов к сервисам;
□ сбор показателей — сбор показателей использования API для анализа, связанного
с биллингом;
□ ведение журнала запросов — запись запросов в журнал.
В вашем приложении есть три участка, где можно реализовать граничные функ
ции. Прежде всего это внутренние сервисы. Они подходят для реализации таких
функций, как кэширование, сбор показателей и, возможно, авторизация. При этом,
чтобы увеличить безопасность, аутентификацию запросов лучше выполнять до того,
как они достигнут сервисов.
Второй участок — это реализация граничных функций в отдельном граничном
сервисе, который находится сразу перед API-шлюзом. Этот сервис будет выступать
первой точкой контакта с внешними клиентами. Он аутентифицирует запрос и вы
полняет другую граничную обработку, прежде чем передать его API-шлюзу.
Важное преимущество от использования отдельного граничного сервиса связано
с разделением ответственности. API-шлюз может сосредоточиться на маршрутизации
и объединении API. Еще одна положительная сторона состоит в централизации ответ
ственности за критически важные функции, такие как аутентификация. Это особенно
полезно в случае, когда у приложения есть несколько API-шлюзов, которые могут быть
написаны с помощью разных языков и фреймворков. Мы поговорим об этом позже.
Недостаток этого подхода — повышенная сетевая латентность из-за дополнительного
сетевого перехода. К тому же это делает приложение более сложным.
В итоге во многих случаях лучше воспользоваться третьим вариантом и реализо
вать граничные функции, особенно аутентификацию, в самом API-шлюзе. Этим мы
устраняем лишний сетевой переход, чем снижаем латентность. К тому же чем
меньше компонентов, тем проще приложение. В главе 11 мы поговорим о том, как
API-шлюз и сервисы обеспечивают безопасность, взаимодействуя друг с другом.
Архитектура API-шлюза
API-шлюз имеет двухуровневую модульную архитектуру. Она состоит из двух частей:
общего уровня и API (рис. 8.5). API состоит из одного или нескольких API-модулей.
Каждый модуль реализует API для конкретного клиента. На общем уровне реализо
ваны общие возможности, включая такие граничные функции, как аутентификация.
312 Глава 8 Шаблоны внешних API
Рис. 8.5. Двухуровневая архитектура API-шлюза. API для каждого клиента реализуется отдельным
модулем. Общий уровень реализует возможности, которые используются во всех API, например
аутентификацию
В этом примере API-шлюз содержит три API-модуля:
□ мобильный API — реализует API для мобильного клиента FTGO;
□ браузерный API — реализует API для JavaScript-приложения, которое работает
в браузере;
□ публичный API — реализует API для сторонних разработчиков.
API-модуль реализует каждую API-операцию одним из двух способов. Неко
торые операции накладываются непосредственно на определенную API-операцию
сервиса, которой впоследствии направляются соответствующие запросы. Перена
правляются запросы с помощью универсального модуля, который считывает кон
фигурационный файл с правилами маршрутизации.
Чтобы реализовать более сложные API-операции, API-модуль использует объ
единение API. Это требует написания дополнительного кода. Каждая реализация
API-операции обрабатывает запросы, обращаясь к нескольким сервисам и объединяя
результаты.
Модель владения в API-шлюзе
Есть еще один важный вопрос, на который вы должны ответить: кто несет ответ
ственность за разработку API-шлюза и его операций? Есть несколько вариантов.
Вы можете выделить для этого отдельную команду. Недостаток такого решения
в том, что оно похоже на архитектуру SOA, где одна команда отвечает за разработку
8.2. Шаблон «API-шлюз» 313
всей сервисной шины предприятия (Enterprise Service Bus, ESB). Если разработчику,
который пишет мобильное приложение, нужно получить доступ к определенному
сервису, он должен обратиться к коллегам из команды API-шлюза и подождать,
когда они предоставят нужный API. Такое централизованное узкое место в органи
зации противоречит философии микросервисной архитектуры, которая поощряет
формирование слабо связанных автономных команд.
Вместо этого стоит воспользоваться подходом, который продвигает компания
Netflix: отдать API-модуль на откуп клиентским разработчикам, занимающимся
мобильными устройствами, веб-приложениями и публичным API. Команда, которая
пишет API-шлюз, отвечает за его эксплуатацию и разработку модуля Common. Такая
модель владения дает командам разработчиков контроль над их API (рис. 8.6).
Рис. 8.6. Клиентская команда владеет своим API-модулем. По мере обновления клиента она может
изменять API-модуль, не спрашивая разрешения у команды API-шлюза
314 Глава 8 • Шаблоны внешних API
Когда команде нужно обновить свой API, она вносит изменения в исходный ре
позиторий API-шлюза. Чтобы сделать процесс разработки API-шлюза надежным,
его следует полностью автоматизировать. В противном случае клиентским коман
дам придется часто останавливать свою работу и ждать, пока команда API-шлюза
не развернет новую версию.
Использование шаблона BFF
Одна из проблем API-шлюза состоит в нечетком разделении ответственности.
Разные команды вносят свой вклад в общую кодовую базу. Команда API-шлюза от
вечает за его эксплуатацию. Это не настолько плохо, как в SOA ESB, но размывание
ответственности противоречит одному из принципов микросервисной архитектуры:
«за компонент отвечает тот, кто его пишет».
Решением этой проблемы является выделение API-шлюза для каждого клиента.
Это шаблон проектирования BFF (backends for frontends — серверы для клиентов),
который был предложен Филом Кальсадо (Phil Calado) (philcalcado.com) и его кол
легами из SoundCloud. На рис. 8.7 показано, как каждый API-модуль превращается
в отдельный API-шлюз, который разрабатывается и администрируется одной кли
ентской командой.
Рис. 8.7. Шаблон BFF отводит каждому клиенту отдельный API-шлюз. Каждая клиентская команда
имеет собственный API-шлюз. Команда API-шлюза отвечает за общий уровень
8.2. Шаблон «API-шлюз» 315
Команда публичного API владеет своим API-шлюзом и отвечает за его админи
стрирование, у мобильной команды — свой API-шлюз, администрированием кото
рого она занимается, и т. д. Теоретически разные API-шлюзы можно разрабатывать
с помощью разных наборов технологий. Но это грозит вылиться в дублирование
общей функциональности, такой как реализация граничных функций. В идеале все
API-шлюзы должны использовать единый технологический стек. Команда API-
шлюза выносит общие возможности в разделяемую библиотеку.
Помимо четкого разделения ответственности, шаблон BFF имеет и другие пре
имущества. API-модули изолированы друг от друга, что повышает надежность.
Одному плохо работающему API будет сложно повлиять на другие. Это улучшает
наблюдаемость, поскольку API-модули выполняются в разных процессах. Еще одна
положительная сторона шаблона BFF — независимое масштабирование каждого
API. Он также уменьшает время запуска, из-за того что каждый отдельный API-
шлюз становится более компактным и простым.
8.2.2. Преимущества и недостатки API-шлюза
Как вы, наверное, и ожидали, шаблон API-шлюза имеет как положительные, так
и отрицательные стороны.
Преимущества API-шлюза
Большое преимущество от использования API-шлюза связано с тем, что он инкап
сулирует внутреннюю структуру приложения. Вместо вызова определенных сер
висов клиенты общаются со шлюзом. Каждый клиент получает отдельный API, что
снижает количество запросов между ним и приложением. К тому же это упрощает
клиентский код.
Недостатки API-шлюза
Шаблон API-шлюза имеет определенные недостатки. Это еще один высокодоступ
ный компонент, который нужно разрабатывать, развертывать и администриро
вать. Вдобавок существует риск того, что API-шлюз начнет тормозить разработку.
Его следует обновлять при «выставлении наружу» API очередного сервиса. Важно,
чтобы процесс обновления был максимально легковесным. В противном случае
разработчики будут вынуждены ждать своей очереди, чтобы обновить API-шлюз.
Несмотря на это, шаблон BFF подходит для большинства реальных приложений.
При необходимости можно применить его, чтобы команды разрабатывали и раз
вертывали свои API независимо друг от друга.
316 Глава 8 • Шаблоны внешних API
8.2.3. Netflix как пример
использования API-шлюза
Отличным примером API-шлюза является Netflix API. Потоковое видео Netflix
доступно на сотнях разных устройств, включая телевизоры, проигрыватели Blu-ray,
смартфоны и многое другое. Изначально компания Netflix пыталась обойтись
единым универсальным API (www.programmableweb.com/news/why-rest-keeps-me-
night/2012/05/15). Но вскоре обнаружилось, что это не самая лучшая идея, поскольку
разные устройства предъявляют разные требования. Сейчас Netflix использует API-
шлюз, который реализует отдельные API для каждого типа устройств, за которые
отвечают команды клиентских разработчиков.
В первой версии API-шлюза каждая клиентская команда реализовывала свой
API с помощью скриптов на языке Groovy, которые выполняли маршрутизацию
и объединение API. Каждый скрипт обращался к API одного или нескольких
сервисов, используя клиентские библиотеки на языке Java, предоставляемые ко
мандами сервисов. В общем-то, все хорошо работало, а клиентские разработчики
написали тысячи скриптов. Каждый день API-шлюз обрабатывал миллиарды за
просов, а каждый API-вызов обращался в среднем к шести или семи внутренним
сервисам. Однако компании Netflix такая монолитная архитектура показалась
несколько громоздкой.
Так что сейчас Netflix переходит на архитектуру API-шлюза, похожую на шаблон
BFF. Теперь клиентские команды пишут API-модули с помощью NodeJS. Каждый
API-модуль запускает собственный Docker-контейнер, но скрипты не вызывают сер
висы напрямую. Вместо этого они обращаются ко второму «API-шлюзу», который
делает доступными API сервисов с использованием Netflix Falcor. Netflix Falcor —
это технология для декларативного динамического объединения API, которая по
зволяет клиенту вызвать несколько сервисов за один запрос. Эта новая архитектура
обладает рядом преимуществ. API-модули изолированы друг от друга, что улучшает
надежность и наблюдаемость. При этом клиентские API-модули поддерживают не
зависимое масштабирование.
8.2.4. Трудности проектирования API-шлюза
Итак, вы познакомились с шаблоном API-шлюза, его преимуществами и недостат
ками. Теперь исследуем трудности, которые могут возникнуть при его проектиро
вании. Есть несколько моментов, на которые следует обратить внимание.
□ Производительность и масштабируемость.
□ Написание поддерживаемого кода с помощью абстракций реактивного програм
мирования.
□ Обработка частичных отказов.
□ Реализация шаблонов, общих для архитектуры приложения.
Обсудим их.
8.2. Шаблон «API-шлюз» 317
Производительность и масштабируемость
API-шлюз — это парадный вход в приложение. Через него должны поступать все
внешние запросы. И хотя большинству компаний далеко до Netflix, где ежедневные
запросы исчисляются миллиардами, производительность и масштабируемость
API-шлюза обычно имеют большое значение. Ключевое архитектурное решение,
которое влияет на эти показатели, заключается в выборе между синхронным и асин
хронным вводом/выводом.
В синхронной модели ввода/вывода каждое сетевое соединение обрабатывается от
дельным потоком. Это простая, неплохо работающая программная модель. Например,
она лежит в основе широко используемого фреймворка сервлетов Java ЕЕ (правда,
в нем есть возможность завершать запросы асинхронно). Однако ограничение син
хронного ввода/вывода связано с тяжеловесностью потоков операционной системы,
в результате ограничивается количество потоков, а вместе с ним и число параллель
ных соединений, которые поддерживает API-шлюз.
Альтернативный подход состоит в применении асинхронной (неблокирующей)
модели ввода/вывода. В ней передачей запросов обработчикам событий занимается
один поток с рабочим циклом. Асинхронный ввод/вывод реализован в различных
технологиях. В JVM можно задействовать один из фреймворков на основе NIO,
включая Netty, Vertx, Spring Reactor и JBoss Undertow. За пределами JVM популяр
ным выбором является NodeJS — платформа, построенная поверх движка JavaScript
из браузера Chrome.
Неблокирующий ввод/вывод гораздо лучше масштабируется, потому что не тратит
лишние ресурсы на создание множества потоков. Его слабая сторона заключается
в намного более сложной модели программирования, основанной на функциях обрат
ного вызова. Это усложняет написание, чтение и отладку кода. Обработчики событий
должны быстро возвращать результат, чтобы не блокировать рабочий цикл потока.
Кроме того, окажет ли неблокирующий ввод/вывод общий положительный эф
фект, зависит от характеристик логики обработки запросов API-шлюза. У Netflix
получились неоднозначные результаты после переписывания Zuul — граничного
сервера с применением NIO (см. medium.com/netflixtechblog/zuul-2-the-netflix-journey-
to-asynchronous-non-blocking-systems-45947377fb5c). С одной стороны, как можно было
ожидать, это снизило расходы на каждое сетевое подключение: для этого больше
не нужно создавать отдельный поток. К тому же при выполнении логики с ин
тенсивным использованием ввода/вывода, такой как маршрутизация запросов,
кластер Zuul продемонстрировал увеличение пропускной способности и снижение
вычислительной нагрузки на 25 %. С другой стороны, при интенсивной работе
с вычислительными операциями, такими как расшифровка и сжатие, кластер Zuul
не показал никаких улучшений.
Использование абстракций реактивного программирования
Как уже упоминалось, объединение API подразумевает вызов нескольких внутрен
них сервисов. Некоторые из этих вызовов полностью зависят от параметров клиент
ского запроса. Другие могут полагаться на результаты запросов к другим сервисам.
318 Глава 8 • Шаблоны внешних API
Одно из решений состоит в том, чтобы обработчик конечной точки API вызывал
сервисы в порядке, основанном на зависимостях. Например, в листинге 8.1 показан
написанный таким образом обработчик запроса findOrder(). Он последовательно,
один за другим вызывает каждый из четырех сервисов.
Листинг 8.1. Извлечение подробностей о заказе путем последовательного обращения
к внутренним сервисам
@RestController
public class OrderDetailsController {
@RequestMapping(”/order/{orderId}")
public OrderDetails getOrderDetails(@PathVariable String orderld) {
Orderinfo orderinfo = orderService.findOrderByld(orderld);
Ticketinfo ticketinfo = kitchenService
.findTicketByOrderld(orderld);
Deliveryinfo deliveryinfo = deliveryservice
.findDeliveryByOrderld(orderld);
Billinfo billinfo = accountingservice
.findBillByOrderld(orderld);
OrderDetails orderDetails =
OrderDetails.makeOrderDetails(orderInfo, ticketinfo,
deliveryinfo, billinfo);
return orderDetails;
}
Недостаток этого подхода состоит в том, что времена ответа каждого из сервисов
суммируются в общее время ответа. Чтобы его минимизировать, логика объеди
нения API должна по возможности обращаться к сервисам параллельно. В этом
примере между вызовами нет никаких зависимостей, поэтому все они должны
быть выполнены в конкурентном стиле. Это существенно снизит время ожидания.
Основная трудность заключается в написании конкурентного кода, который будет
несложно поддерживать.
Дело в том, что традиционно для написания масштабируемого конкурентного
кода используются функции обратного вызова. Это в наибольшей степени отно
сится к асинхронному событийному вводу/выводу. Функции обратного вызова
обычно применяются даже в API-композиторе на основе сервлетов, который вы
зывает сервисы параллельно. Для конкурентного выполнения запросов он может
вызывать метод ExecutorService.submitCallable(). Но проблема в том, что этот
метод возвращает объект Future, который имеет блокирующий API. Чтобы добить
ся лучшей масштабируемости, API-композитор мог бы воспользоваться методом
ExecutorService.submit(Runnable) и затем передать результат каждого запроса
в функцию обратного вызова. Когда все результаты будут получены, она возвратит
ответ клиенту.
8.2. Шаблон «API-шлюз» 319
Написание кода для объединения API с применением традиционного асинхрон
ного подхода очень быстро приводит к ситуации, которую называют адом обратных
вызовов (callback hell). Код становится запутанным, сложным для понимания и на
чинает провоцировать ошибки, особенно когда для объединения требуется сочетание
параллельных и последовательных запросов. Намного более удачным подходом
является использование декларативного стиля с реактивными методиками. В каче
стве примера реактивных абстракций в JVM можно привести следующие:
□ CompletableFuture в Java 8;
□ Monos из Project Reactor;
□ Observable из библиотеки Rxjava (Reactive Extensions for Java — реактивные рас
ширения для Java), созданной компанией Netflix специально для решения этой
проблемы в ее API-шлюзе;
□ Future в Scala.
API-шлюз, основанный на NodeJS, может использовать встроенные объекты
Promise или реактивные расширения для JavaScript, RxJS. Любая из этих двух
абстракций позволит вам писать конкурентный код, который потом легко будет
понять. Позже в этой главе я продемонстрирую этот стиль программирования на
примере класса Monos из Project Reactor и пятой версии Spring Framework.
Обработка частичных отказов
API-шлюз должен быть не только масштабируемым, но и надежным. Чтобы этого
добиться, его можно запускать в нескольких экземплярах, размещенных за балан
сировщиком нагрузки. Если один из них откажет, балансировщик перенаправит
запросы на другие экземпляры.
Еще один способ обеспечения надежности API-шлюза заключается в правильной
обработке запросов, которые завершились неудачей или имеют слишком высокую
латентность. Когда API-шлюз обращается к сервису, всегда существует вероятность
того, что тот окажется медленным или недоступным. Иногда ответа приходится
ждать очень долго, даже бесконечно, что отнимает ресурсы и не дает ответить кли
енту. Затянувшийся запрос к неисправному сервису может даже расходовать такой
ограниченный и ценный ресурс, как системные потоки, из-за чего API-шлюз будет
не в состоянии обработать другие запросы. Решение этой проблемы описано в главе 3:
при вызове сервисов API-шлюз должен использовать шаблон «Предохранитель».
Реализация шаблонов, общих для архитектуры приложения
В главе 3 я описал шаблоны для обнаружения сервисов, а в главе 11 вы познакоми
тесь с шаблонами для обеспечения наблюдаемости. Шаблоны обнаружения сервисов
позволяют клиенту, например API-шлюзу, определить сетевое местоположение эк
земпляра сервиса, чтобы обратиться к нему. Шаблоны обеспечения наблюдаемости
позволяют разработчикам отслеживать поведение приложения и диагностировать
возникающие проблемы. API-шлюз, как и другие сервисы, должен реализовать ша
блоны, выбранные для заданной архитектуры.
320 Глава 8 • Шаблоны внешних API
8.3. Реализация API-шлюза
Теперь посмотрим, как реализовать API-шлюз. Ранее уже упоминалось, что у него
есть следующие обязанности:
□ маршрутизация запросов — маршрутизация запросов к сервисам на основе таких
критериев, как HTTP-метод или путь. Если приложение содержит несколько
сервисов для CQRS-запросов, API-шлюз должен учитывать HTTP-метод при
маршрутизации. Как упоминалось в главе 7, в такой архитектуре команды и за
просы обрабатываются разными сервисами;
□ объединение API — реализация конечной точки REST с методом GET с помощью
объединения API (см. главу 7). Обработчик запросов объединяет результаты
вызова нескольких сервисов;
□ граничные функции — самой примечательной из них является аутентификация;
□ преобразование протоколов — взаимодействие между протоколами клиентской
стороны и теми, которые используются сервисами и плохо совместимы с кли
ентами;
□ реализация шаблонов, общих для архитектуры приложения.
API-шлюз можно реализовать несколькими способами:
□ используя готовый пробукт/сервис. Этот вариант почти (или совсем) не требует
разработки, но он менее гибок. Например, готовые API-шлюзы обычно не под
держивают объединение API;
□ путем разработки собственного API-шлюза с применением специального (веб-)
фреймворка в качестве отправной точки. Это самый гибкий подход, но он требует
от разработчиков определенных усилий.
Рассмотрим оба варианта.
8.3.1. Использование готового API-шлюза
Функции API-шлюза реализованы в нескольких готовых сервисах и продуктах.
Вначале обсудим сервисы, предоставляемые в рамках AWS. Затем поговорим о не
скольких продуктах, которые вы можете загрузить, сконфигурировать и применять
самостоятельно.
AWS API Gateway
AWS API Gateway — это один из множества сервисов, предоставляемый компанией,
он предназначен для развертывания и администрирования API. Его API представляет
собой набор ресурсов REST, каждый из которых поддерживает один или несколько
HTTP-методов. В его конфигурации нужно указать, к какому внутреннему сервису
следует направлять каждую пару «метод — ресурс». В качестве внутреннего сервиса
выступает либо лямбда-функция AWS (см. главу 12), либо HTTP-сервис, определен
ный на уровне приложения, либо AWS-сервис. При необходимости API можно скон-
8.3. Реализация API-шлюза 321
фигурировать так, чтобы он преобразовывал запросы и ответы, используя механизм
шаблонов. AWS API Gateway умеет также аутентифицировать запросы.
AWS API Gateway удовлетворяет некоторым требованиям к API-шлюзу, опи
санным ранее. Это облачный сервис AWS, поэтому вам не нужно беспокоиться
о его установке и администрировании — достаточно его сконфигурировать, a AWS
позаботится обо всем остальном, включая масштабирование.
К сожалению, у AWS API Gateway есть несколько недостатков и ограничений,
которые делают невозможным выполнение остальных требований. Он не поддер
живает объединение API, поэтому вам придется реализовать эту возможность на
уровне внутренних сервисов. Он поддерживает только HTTP(S) с большим упором
на JSON и обнаружение на стороне сервера, описанное в главе 3. Приложения обыч
но используют Elastic Load Balancer для распределения запросов между серверами
ЕС2 или контейнерами ECS. Но, несмотря на эти ограничения, если вы способны
обойтись без объединения API, AWS API Gateway может послужить хорошей реа
лизацией API-шлюза.
AWS Application Load Balancer
У компании Amazon есть еще один сервис, похожий на API-шлюз. Речь идет о ба
лансировщике нагрузки AWS Application, который поддерживает HTTP, HTTPS,
WebSocket и НТТР/2 (aws.amazon.com/blogs/aws/new-aws-application-load-balancer/).
Чтобы его сконфигурировать, нужно определить правила маршрутизации, которые
будут направлять запросы к внутренним сервисам (в данном случае это серверы
AWS ЕС2).
Как и AWS API Gateway, AWS Application Load Balancer удовлетворяет неко
торым требованиям, предъявляемым к API-шлюзу. Он реализует базовую функ
циональность для маршрутизации. Он находится в облаке, поэтому вам не нужно
беспокоиться о его установке и администрировании. К сожалению, он довольно
ограничен: не поддерживает маршрутизацию на основе HTTP-методов, объединение
API и аутентификацию. В связи с этим AWS Application Load Balancer не подходит
на роль API-шлюза.
Готовый продукт в качестве API-шлюза
Еще один вариант — применение готовых продуктов, таких как Kong или Traefik.
Это пакеты с открытым исходным кодом, которые можно устанавливать и исполь
зовать самостоятельно. Kong основан на HTTP-сервере NGINX, a Traefik написан
на языке Go Lang. Оба продукта позволяют настраивать гибкие правила маршрутиза
ции для выбора внутренних сервисов с учетом HTTP-методов, заголовков и путей.
Kong предоставляет расширения для реализации таких граничных функций, как
аутентификация. Traefik может даже интегрироваться с некоторыми реестрами
сервисов, описанными в главе 3.
Несмотря на поддержку граничных функций и гибкой маршрутизации, эти про
дукты имеют определенные недостатки. На вас ложатся их установка, настройка
и администрирование. К тому же они не поддерживают объединение API, поэтому,
если вам необходима эта возможность, придется разработать собственный API-шлюз.
322 Глава 8 Шаблоны внешних API
8.3.2. Разработка собственного API-шлюза
В разработке API-шлюза ничего особо сложного. Это, в сущности, веб-приложение,
которое проксирует запросы к другим сервисам. Вы можете создать его сами, ис
пользуя любимый веб-фреймворк. Однако при проектировании API-шлюза вам
придется решить две ключевые проблемы:
□ реализовать механизм определения правил маршрутизации, чтобы минимизи
ровать написание сложного кода;
□ правильно реализовать НТТР-проксирование, в том числе обработку НТТР-
заголовков.
Таким образом, разработку API-шлюза лучше всего начать с выбора фреймворка,
предназначенного для этой задачи. Его встроенные возможности значительно снизят
объем кода, который вам придется писать.
Вначале мы рассмотрим проект с открытым исходным кодом Zuul от компании
Netflix, а затем познакомимся с открытым проектом Spring Cloud Gateway от Pivotal.
Использование Netflix Zuul
Для реализации таких граничных функций, как маршрутизация, ограничение ча
стоты запросов и аутентификация, компания Netflix разработала фреймворк Zuul
(github.com/Netflix/zuul). В нем применяется концепция фильтров — перехватчиков
запросов с возможностью повторного использования, похожих на фильтры серв
летов или промежуточный слой в NodeJS Express. Для обработки НТТР-запроса
Zuul собирает цепочку подходящих фильтров, которые выполняют преобразование,
вызывают внутренние сервисы и форматируют ответ, перед тем как вернуть его
клиенту. Zuul можно применять напрямую, однако открытый проект Spring Cloud
Zuul от компании Pivotal намного упрощает задачу. Этот проект основан на Zuul,
но благодаря своей концепции соглашения по конфигурации он заметно облегчает
разработку Zuul-серверов.
Zuul поддерживает маршрутизацию и граничные функции. Для его расширения
можно использовать контроллеры Spring MVC, которые реализуют объединение
API. Тем не менее Zuul реализует лишь маршрутизацию на основе путей, и это его
основное ограничение. Например, он не может направить запрос GET /orders к од
ному сервису, a POST /orders — к другому. По этой причине Zuul не поддерживает
архитектуру запросов, описанную в главе 7.
О проекте Spring Cloud Gateway
Ни один из описанных до сих пор вариантов не отвечает всем требованиям. На самом
деле я уже было сдался и прекратил поиски подходящего фреймворка, начав разра
ботку API-шлюза с помощью Spring MVC. Но затем открыл для себя проект Spring
Cloud Gateway (cloud.spring.io/spring-cloud-gateway/). Он построен на основе нескольких
фреймворков — Spring Framework 5, Spring Boot 2 и Spring Webflux. Последний вхо-
8.3. Реализация API-шлюза 323
дит в состав Spring Framework 5, построен поверх Project Reactor и предоставляет
реактивные абстракции. Project Reactor — это реактивный фреймворк для JVM на
основе NIO, он реализует абстракцию Mono, которой мы воспользуемся позже в этой
главе.
Spring Cloud Gateway предоставляет простой, но в то же время комплексный
подход к решению следующих задач:
□ направление запросов к внутренним сервисам;
□ реализация обработчиков запросов, которые выполняют объединение API;
□ реализация граничных функций, таких как аутентификация.
Ключевые компоненты API-шлюза, построенного с использованием этого фрейм
ворка, представлены на рис. 8.8.
Рис. 8.8. Архитектура API-шлюза, созданного с помощью Spring Cloud Gateway
324 Глава 8 • Шаблоны внешних API
API-шлюз состоит из следующих пакетов.
□ Пакет ApiGatewayMain — определяет главную программу для API-шлюза.
□ Один или несколько API-пакетов — API-пакет реализует набор конечных точек
API. Например, пакет Order реализует конечные точки, связанные с заказами.
□ Прокси-пакеты — состоит из прокси-классов, с помощью которых API-пакеты
обращаются к сервисам.
Класс Orderconfiguration определяет объекты Spring Bean, ответственные за
маршрутизацию запросов, относящихся к заказам. Правило маршрутизации может
соответствовать комбинации HTTP-метода, заголовков и пути. orderProxyRoutes
@Веап определяет правила, которые связывают API-операции с URL-адресами
внутреннего сервиса. Например, этот объект направляет все пути, начинающиеся
с /orders, к сервису Order.
Правила orderHandlers @Веап переопределяют те, что были определены объектом
orderProxyRoutes. Они связывают API-операции с методами-обработчиками Spring
WebFlux, которые являются эквивалентом контроллеров в Spring MVC. Напри
мер, orderHandlers привязывает запрос GET /orders/{orderld} к методу OrderHand
lers: :getOrderDetails().
Класс OrderHandlers реализует различные методы для обработки запросов, такие
как OrderHandlers: :getOrderDetails(). Этот метод извлекает подробности о заказе
за счет объединения API, как было описано ранее. Обработчики обращаются к вну
тренним сервисам с помощью удаленных прокси-классов, таких как OrderService.
Этот класс определяет методы для обращения к сервису Order.
Рассмотрим код, начиная с класса OrderConf iguration.
Класс Orderconfiguration
Класс OrderConf iguration, показанный в листинге 8.2, помечен аннотацией @Conf igura
tion из состава Spring. Он определяет объекты @Веап, которые реализуют конечные точ
ки /orders. Такие объекты @Bean, KaKorderProxyRouting и orderHandlerRouting, описы
вают маршрутизацию запросов с помощью DSL-языка Spring WebFlux. orderHandlers
@Веап реализует обработчики запросов, выполняющие объединение API.
Листинг 8.2. Объекты @Веап из состава Spring, которые реализуют конечные точки /orders
(^Configuration
@EnableConfigurationProperties(OrderDestinations.class)
public class Orderconfiguration {
@Bean
public RouteLocator orderProxyRouting(OrderDestinations orderDestinations) {
return Routes.locator()
.route("orders")
.uri(orderDestinations.orderServiceUrl)
.predicate(path("/orders").or(path("/orders/*"))) ◄-------------------
‘anc* []0 умолчанию направляет все запросы,
.build(); начинающиеся с/orders,
} на URL-адрес orderDestinations.orderServiceUrl
8.3. Реализация API-шлюза 325
@Bean
public RouterFunction<ServerResponse>
orderHandlerRouting(OrderHandlers orderHandlers) {
Направляет GET /orders/forderld}
к orderHandlers::getOrderDetails
return RouterFunctions.route(GET("/orders/{orderId}"), <
orderHandlers:igetOrderDetails);
@Bean
public OrderHandlers orderHandlers(OrderService OrderService,
KitchenService kitchenService,
Deliveryservice deliveryservice,
Accountingservice accountingservice) {
return new OrderHandlers(orderService, kitchenService, ◄--------------------------
deliveryservice, accountingservice);
Объект @Bean, реализующий
пользовательскую логику
обработки запросов
Класс OrderDestinations, представленный в листинге 8.3, помечен аннотацией
@Conf igurationProperties из состава Spring, что позволяет использовать внешнюю
конфигурацию URL-адресов внутреннего сервиса.
Листинг 8.3. Внешняя конфигурация URL-адресов внутреннего сервиса
@ConfigurationProperties(prefix = "order.destinations")
public class OrderDestinations {
@NotNull
public String orderServiceUrl;
public String getOrderServiceUrl() {
return orderServiceUrl;
}
public void setOrderServiceUrl(String orderServiceUrl) {
this.orderServiceUrl = orderServiceUrl;
}
}
К примеру, URL-адрес сервиса Order можно указать либо в виде свойства
order .destinations. orderServiceUrl в конфигурационном файле, либо как пере
менную окружения ORDER_DESTINATIONS_ORDER_SERVICE_URL.
Класс OrderHandlers
Класс OrderHandlers, показанный в листинге 8.4, определяет методы для обработки
запросов, которые реализуют нестандартное поведение, включая объединение API.
Метод getOrderDetails(), к примеру, объединяет API для извлечения информации
о заказе. В этот класс внедряется несколько прокси-классов, которые отправляют
запросы к внутренним сервисам.
326 Глава 8 • Шаблоны внешних API
Листинг 8.4. Класс OrderHandlers реализует пользовательскую логику для обработки запросов
public class OrderHandlers {
private OrderService orderservice;
private KitchenService kitchenService;
private Deliveryservice deliveryservice;
private Accountingservice accountingservice;
public OrderHandlers(OrderService orderservice,
KitchenService kitchenService,
Deliveryservice deliveryservice,
Accountingservice accountingservice) {
this.OrderService = orderservice;
this.kitchenService = kitchenService;
this.deliveryservice = deliveryservice;
this.accountingservice = accountingservice;
}
public Mono<ServerResponse> getOrderDetails(ServerRequest serverRequest) {
String orderld = serverRequest.pathVariable("orderId");
Mono<OrderInfo> orderinfo = orderService.findOrderByld(orderld);
Mono<Optional<TicketInfo>> ticketinfo =
kitchenService
.findTicketByOrderld(orderld)
.map(Optional::of) 4-------
.onErrorReturn(Optional.empty()); ◄—
Mono<Optional<DeliveryInfo>> deliveryinfo =
deliveryservice
.findDeliveryByOrderld(orderld)
.map(Optional::of)
.onErrorReturn(Optional.empty());
Преобразуем Ticketlnfb
в Optional<Tkketlnfo>
Если обращение к сервису было
неудачным, возвращаем Optional.emptyO
Mono<Optional<BillInfo>> billinfo = accountingservice
.findBillByOrderld(orderld)
.map(Optional::of)
.onErrorReturn(Optional.empty()); Объединяем четыре
значения в одно, Tuple4
Mono<Tuple4<0rderInfo, Optional<TicketInfo>, ◄--------
Optional<DeliveryInfo>, Optional<BillInfo>>> combined =
Mono.when(orderinfo, ticketinfo, deliveryinfo, billinfo);
Mono<OrderDetails> orderDetails =
combined.map(OrderDetails::makeOrderDetails); Преобразуем Tuple4
в OrderDetails
return orderDetails.flatMap(person -> ServerResponse.ok() <
.contentType(MediaType,APPLICATION_JSON)
.body(fromObject(person))); Преобразуем OrderDetails
} BServerResponse
8.3. Реализация API-шлюза 327
Метод getOrderDetails() выполняет объединение API, чтобы получить по
дробности о заказе. Он написан в масштабируемом реактивном стиле с помощью
абстракции Mono из состава Project Reactor. Класс Mono, который является более
развитой версией CompletableFuture из Java 8, содержит результат асинхронной
операции — значение или исключение. Он обладает гибким API для объединения
значений, возвращаемых асинхронными операциями. Вы можете использовать его
для написания конкурентного кода в простом и понятном стиле. В этом примере
метод getOrderDetails() параллельно обращается к четырем сервисам и объединяет
полученные результаты в единый объект OrderDetails.
Метод getOrderDetails() принимает в качестве параметра объект ServerRequest,
который в Spring WebFlux представляет собой HTTP-запрос, и делает следующее.
1. Извлекает из пути orderld.
2. Асинхронно вызывает четыре сервиса через их прокси, получая в ответ объекты
Mono. Для улучшения доступности метод getOrderDetails() считает ответы всех
сервисов, кроме Order, необязательными. Если объект Mono, возвращенный необя
зательным сервисом, содержит исключение, вызов onErrorReturn() преобразует
его в Mono с пустым экземпляром Optional внутри.
3. Асинхронно объединяет результаты, используя метод Mono. when (), который воз
вращает Mono<Tuple4> с четырьмя значениями.
4. Преобразует Mono<Tuple4> в Mono<OrderDetails> с помощью OrderDetails: :ma-
keOrderDetails.
5. Преобразует OrderDetails в объект ServerResponse, который в Spring WebFlux
представляет собой ответ в формате JSON/HTTP.
Как видите, благодаря использованию объектов Mono метод getOrderDetailsQ
обращается к сервисам и объединяет результаты без применения запутанных и слож
ных для восприятия функций обратного вызова. Давайте отдельно остановимся на
прокси одного из сервисов, который возвращает результаты API-вызова, завернутые
в объект Mono.
Класс OrderService
Класс OrderService, показанный в листинге 8.5, служит удаленным прокси для
сервиса Order. Для обращения к этому сервису он задействует реактивный НТТР-
клиент WebClient из состава Spring WebFlux.
Листинг 8.5. Класс OrderService — удаленный прокси для сервиса Order
^Service
public class OrderService {
private OrderDestinations OrderDestinations;
private WebClient client;
public OrderService(OrderDestinations OrderDestinations, WebClient client)
328 Глава 8 • Шаблоны внешних API
{
this.orderDestinations = orderDestinations;
this.client = client;
}
public Mono<OrderInfo> findOrderById(String orderld) {
Mono<ClientResponse> response = client
•get()
.uri(orderDestinations.orderServiceUrl + "/orders/{orderld}",
.exchangeQ;5 «-------------------- 1 Вызываем сервис
return response.flatMap(resp -> resp.bodyToMono(Orderlnfo.class)); ◄
}
Преобразуем тело ответа в Orderinfo
}
Метод findOrder() извлекает объект Orderinfo с заказом. Для выполнения
HTTP-запроса к сервису он применяет WebClient, а затем десериализует ответ фор
мата JSON в Orderinfo. WebClient обладает реактивным API и заворачивает ответы
в объекты Mono. Метод f indOrder() использует вызов flatMap(), чтобы преобразовать
Mono<ClientResponse> в Mono<OrderInfo>. А метод bodyToMono(), как понятно из на
звания, возвращает тело ответа в виде Mono.
Класс ApiGatewayApplication
Класс ApiGatewayApplication, представленный в листинге 8.6, реализует метод
main () API-шлюза. Это стандартный главный класс в Spring Boot.
Листинг 8.6. Метод main() для API-шлюза
@SpringBootConfiguration
@EnableAutoConfiguration
@EnableGateway
^Import(OrdersConfiguration.class)
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
}
Аннотация @EnableGateway импортирует конфигурацию Spring для фреймворка
Spring Cloud Gateway.
Spring Cloud Gateway отлично подходит для реализации API-шлюза. Он позво
ляет сконфигурировать базовое проксирование, используя простой и лаконичный
язык DSL для описания правил маршрутизации. С его помощью можно легко на
правлять запросы к методам-обработчикам, которые объединяют API и преобразуют
протоколы. Этот фреймворк построен поверх масштабируемых и реактивных про
ектов Spring Framework 5 и Project Reactor. Но в вашем распоряжении есть еще один
вариант для написания собственного API-шлюза — фреймворк GraphQL, который
предоставляет язык запросов на основе графов. Посмотрим, как это работает.
8.3. Реализация API-шлюза 329
8.3.3. Реализация API-шлюза с помощью GraphQL
Представьте, что вам доверили реализацию конечной точки GET /orders/forderld}
для API-шлюза FTGO, которая возвращает подробности о заказе. На первый взгляд
эта задача выглядит несложной. Но, как говорилось в разделе 8.1, эта конечная точка
извлекает данные из разных сервисов. Следовательно, вам необходимо использовать
шаблон объединения API и написать код, который обращается к сервисам и совме
щает их ответы.
Еще одна трудность, о которой упоминалось ранее, состоит в том, что разным
клиентам нужны немного разные данные. Например, настольное SPA-приложение,
в отличие от мобильного, показывает, какую оценку вы поставили заказу. Чтобы по
догнать данные под конкретные потребности, можно позволить клиенту указывать
нужную информацию (см. главу 3). Конечная точка, к примеру, может поддерживать
параметры expand и fields, первый будет задавать сопутствующие ресурсы, а вто
рой — поля каждого ресурса, которые следует вернуть. Можно также определить
несколько версий данной конечной точки в рамках шаблона BFF. Это довольно хло
потно, учитывая, что рассматриваемый API-вызов является далеко не единственным
в API-шлюзе приложения FTGO.
Написание API-шлюза с интерфейсом REST API с хорошей поддержкой широ
кого спектра клиентов занимает много времени. В связи с этим вам стоит обратить
внимание на графовый API-фреймворк GraphQL, который специально создан для
эффективного извлечения данных. Суть таких фреймворков заключается в том, что
API сервера имеет структуру графа (рис. 8.9). Графовая структура (схема) задает
набор узлов (типов), имеющих свойства (поля) и связи с другими узлами. Чтобы
извлечь данные, клиент выполняет запрос, который описывает необходимую ин
формацию в виде узлов графа, их свойств и связей. В итоге клиент может извлечь
нужные ему данные за одно обращение к API-шлюзу.
Рис. 8.9. Интерфейс API-шлюза имеет графовую структуру, которая накладывается на сервисы.
Клиент выполняет запрос, извлекающий разные узлы графа. API-фреймворк на основе последнего
выполняет запрос, извлекая данные из одного или нескольких сервисов
330 Глава 8 • Шаблоны внешних API
Технология графовых API имеет несколько важных преимуществ. Она позволяет
клиенту контролировать возвращаемые данные. Это позволяет создавать единый
API, обладающий достаточной гибкостью для поддержки разного рода клиентов.
Еще одно преимущество этого подхода — то, что, несмотря на гибкость API, он зна
чительно облегчает разработку. Это связано с тем, что для написания серверного
кода используется фреймворк выполнения запросов, специально созданный для под
держки объединения и отображения API. Можно привести такую аналогию: вместо
того чтобы заставлять клиенты извлекать данные с помощью хранимых процедур,
которые нужно отдельно писать и поддерживать, вы позволяете им выполнять за
просы к исходной базе данных.
В этом разделе мы поговорим о том, как разработать API-шлюз с помощью Apollo
GraphQL. Я остановлюсь на нескольких ключевых возможностях этого сервера
и GraphQL в целом. Подробности ищите в документации к этим проектам.
На рис. 8.10 показан API-шлюз, основанный на GraphQL и написанный на
JavaScript с использованием веб-фреймворка NodeJS Express и сервера Apollo
GraphQL. Эта архитектура имеет следующие ключевые аспекты.
□ Схема GraphQL описывает модель серверных данных и запросы, которые она
поддерживает.
□ Функции сопоставления накладывают элементы схемы на различные внутренние
сервисы.
□ Прокси-классы обращаются к сервисам приложения FTGO.
8.3. Реализация API-шлюза 331
Р
и
с.
8
.1
0.
А
рх
ит
ек
ту
ра
A
PI
-ш
лю
за
F
TG
O
н
а
ос
но
ве
G
ra
ph
Q
L
332 Глава 8 • Шаблоны внешних API
Есть также небольшое количество связующего кода, который интегрирует сервер
GraphQL с веб-фреймворком Express. Рассмотрим каждый из этих пунктов, начиная
со схемы GraphQL.
Описание схемы GraphQL
Спецификация GraphQL API построена вокруг концепции схемы (schema) — набора
типов, которые определяют структуру модели серверных данных и операции, до
ступные для клиента, например запросы. GraphQL поддерживает несколько разных
типов. В примере кода, представленного в этом разделе, используются только два:
объектные типы, которые являются основным способом описания модели данных,
и перечисления, похожие на тип enum в Java. Объектные типы имеют имя и набор
типизированных именованных полей. Поле может быть скалярным значением
(число, строка или перечисление), списком скалярных значений, ссылкой на дру
гой объектный тип или набором ссылок на другой объектный тип. Это напоминает
поле традиционного объектно-ориентированного класса, однако в GraphQL поля
концептуально являются функциями, которые возвращают значения. Они могут
иметь аргументы, что позволяет клиенту подгонять возвращаемые данные под
свои нужды.
Поля в GraphQL используются также для описания запросов, которые под
держивает схема. Чтобы определить такой запрос, нужно объявить объектный тип,
который принято называть Query. Каждое поле объекта Query — это именованный
запрос с набором опциональных параметров и возвращаемым типом. Когда я впер
вые столкнулся с таким способом описания запросов, он мне показался слегка за
путанным, однако не стоит забывать, что в GraphQL поля являются функциями.
Все станет намного понятней, когда мы посмотрим, как эти поля связаны с внутрен
ними источниками данных.
В листинге 8.7 показан фрагмент схемы API-шлюза FTGO на основе GraphQL.
В нем определены несколько объектных типов. Большинство из них относятся
к элементам приложения FTGO Consumer, Order и Restaurant. Вы также можете
видеть объектный тип Query, который описывает запросы схемы.
Несмотря на другой синтаксис, объектные типы Consumer, Order, Restaurant
и Deliveryinfo по своей структуре напоминают соответствующие Java-классы. Одним
из отличий является тип ID, который представляет уникальный идентификатор.
Эта схема определяет три запроса:
□ orders() — возвращает заказы для заданного клиента;
□ order () — возвращает заданный заказ;
□ consumer () — возвращает информацию о заданном клиенте.
На первый взгляд эти запросы незначительно отличаются от соответствующих
конечных точек REST, однако GraphQL дает клиенту потрясающий контроль над
возвращаемыми данными. Чтобы лучше в этом разобраться, посмотрим, как клиент
выполняет запросы GraphQL.
8.3. Реализация API-шлюза 333
Листинг 8.7. Схема GraphQL для API-шлюза FTGO
type Query { ◄----------
orders(consumerld : Int!): [Order] Определяет запросы, которые
order(orderld : Int!): Order может выполнять клиент
consumer(consumerld : Inti): Consumer
}
type Consumer { I Уникальный ID для заказчика
id: ID ◄------------1
firstName: String
lastName: String
orders: [Order] ◄----------- 1 „
у | У заказчика есть список заказов
type Order {
orderld: ID,
consumerld : Int,
consumer: Consumer
restaurant: Restaurant
deliveryinfo : Deliveryinfo
}
type Restaurant {
id: ID
name: String
}
type Deliveryinfo {
status : Deliverystatus
estimatedDeliveryTime : Int
assignedCourier :String
}
enum Deliverystatus {
PREPARING
READY_FOR_PICKUP
PICKED_UP
DELIVERED
}
Выполнение запросов GraphQL
Принципиальное преимущество технологии GraphQL — ее язык запросов, который
дает клиенту невероятный контроль над возвращаемыми данными. Клиент отправ
ляет серверу документ, содержащий запрос. В простейшем случае в нем указаны
название запроса, значения аргументов и поля объекта, который нужно вернуть.
334 Глава 8 • Шаблоны внешних API
Далее приведен простой запрос, который извлекает поля f irstName и lastName, при
надлежащие заказчику с определенным ID:
query {
consumer(consumerld:l) <
Указывает запрос consumer,
который извлекает данные о заказчике
{
firstName
lastName
Поля объекта Consumer,
которые нужно вернуть
Этот запрос возвращает поля заданного объекта Consumer.
А вот более сложный запрос, возвращающий сведения о заказчике, его заказы,
а также ID и название каждого ресторана, который эти заказы выполнил:
query {
consumer(consumerld:1) {
id
firstName
lastName
orders {
orderld
restaurant {
id
name
}
deliveryinfo {
estimatedDeliveryT ime
name
}
}
}
}
Этот запрос просит сервер вернуть нечто большее, чем просто поля объекта
Consumer. Он извлекает заказы, которые сделал клиент, и информацию о ресторане,
упомянутом в каждом из заказов. Как видите, клиент GraphQL может указать, какие
именно данные следует вернуть, включая поля транзитивно связанных объектов.
Этот язык запросов более гибок, чем кажется на первый взгляд. Дело в том, что
запрос находится в поле объекта Query, а документ указывает серверу, какие из этих
полей он должен вернуть. В этих простых примерах мы извлекаем одно поле, но если
в документе задать несколько полей, он выполнит соответствующее число запросов.
Документ указывает, в каких полях он заинтересован, и предоставляет им подходящие
аргументы. Далее показан запрос, который извлекает двух разных заказчиков:
query {
cl: consumer (consumerld:1) { id, firstName, lastName}
c2: consumer (consumerld:2) { id, firstName, lastName}
}
8.3. Реализация API-шлюза 335
В этом документе используются так называемые псевдонимы cl и с2. Они позво
ляют различать в итоговом объекте двух заказчиков, которые сами по себе имеют
одно и то же название — consumer. В этом примере извлекаются два объекта одного
типа, но клиент может запрашивать и объекты разных типов.
Схема GraphQL определяет форму данных и поддерживаемые запросы. Чтобы
она имела какое-то практическое применение, ее следует подключить к источнику
данных. Посмотрим, как это делается.
Подключение схемы к источнику данных
Когда сервер GraphQL выполняет запрос, он должен извлечь запрошенные данные
из одного или нескольких хранилищ. В случае с приложением FTGO ему необхо
димо обратиться к API сервисов, которые владеют данными. Чтобы подключить
схему GraphQL к источнику данных, к полям ее объектных типов следует прикре
пить функции сопоставления. Сервер GraphQL реализует шаблон объединения
API путем вызова этих функций — сначала для запросов верхнего уровня, а затем
рекурсивно для полей итогового объекта или объектов.
То, как функции сопоставления связываются со схемой, зависит от выбранного
вами сервера GraphQL. В листинге 8.8 показано, как определить сопоставители
при использовании Apollo GraphQL. Вы должны создать двухуровневый объект
JavaScript. Каждое свойство верхнего уровня соотносится с объектным типом, таким
как Query или Order. Каждое свойство второго уровня, такое как Order. consumer,
определяет функцию сопоставления поля.
Листинг 8.8. Прикрепление функций сопоставления к полям схемы в GraphQL
const resolvers = {
^orders: resolveOrders, «------- 1 Сопоставитель для запросов orders
consumer: resolveconsumer,
order: resolveOrder
b
Order. { ____ I Сопоставитель для поля consumer в Order
consumer: resolveOrderConsumer, ◄------- 1
restaurant: resolveOrderRestaurant,
deliveryinfo: resolveOrderDeliverylnfо
b
Функция сопоставления имеет три параметра.
□ Объект — для поля запроса верхнего уровня это корневой объект, обычно
игнорируемый функцией сопоставления. Но это может быть и значение, ко
торое сопоставитель возвращает родительскому объекту. Например, функции
сопоставления для поля Order. consumer передается значение, возвращенное
сопоставителем Order.
336 Глава 8 • Шаблоны внешних API
□ Аргументы запроса — поставляются документом запроса.
□ Контекст — глобальное состояние выполнения запроса, доступное всем сопо-
ставителям. Оно используется, к примеру, для передачи сопоставителям инфор
мации и зависимостей.
Функция сопоставления может вызывать один сервис или реализовать шаблон
объединения API, чтобы извлекать данные из нескольких сервисов. Функции сопо
ставления в сервере Apollo GraphQL возвращают JavaScript-объект Promise, который
является аналогом CompletableFuture в Java. Он содержит объект (или список объ
ектов), извлеченный функцией сопоставления из хранилища данных. Ядро GraphQL
включает возвращаемое значение в итоговый объект.
Рассмотрим несколько примеров. Далее показана функция resolveOrders(),
которая выступает сопоставителем для запроса orders:
function resolveOrders(_, { consumerld }, context) {
return context.OrderServiceProxy.findOrders(consumerld);
}
Эта функция достает из контекста объект OrderServiceProxy и использу
ет его для извлечения заказов клиента. Она игнорирует свой первый параметр.
Аргумент consumerld, предоставленный документом запроса, передается в ме
тод OrderServiceProxy.findOrders(), извлекающий заказы клиента из OrderHi-
storyService.
Далее представлена функция resolveOrderRestaurant(), которая выполняет со
поставление для поля Order. restaurant, извлекая ресторан заказа:
function resolveOrderRestaurant({restaurantId}> args, context) {
return context.restaurantServiceProxy.findRestaurant(restaurantId);
}
Ее первый параметр — Order. Она вызывает RestaurantServiceProxy.findRestau-
rant() с полем restaurantld заказа, который предоставил метод resolveOrders().
Для вызова функций сопоставления GraphQL применяет рекурсивный алгоритм.
Сначала выполняются функции для запроса верхнего уровня, указанного в докумен
те Query. Затем перебираются поля каждого возвращенного объекта, перечисленные
в документе. Если у поля есть сопоставитель, он вызывается с объектом и аргумен
тами, взятыми из того же документа. После этого алгоритм рекурсивно перебирает
объект или объекты, возвращенные сопоставителем.
На рис. 8.11 показано, как этот алгоритм выполняет запрос, который извлека
ет заказы клиента, а также информацию о доставке и ресторанах для каждого из
них. Вначале ядро GraphQL вызывает функцию resolveConsumer(), извлекающую
объект Consumer. Далее она выполняет сопоставитель для поля Consumer .orders,
resolveConsumerOrders(), который возвращает заказы клиента. Затем ядро GraphQL
перебирает объекты Order, вызывая функции сопоставления для полей Order. res
taurant и Order.deliveryinfo.
8.3. Реализация API-шлюза 337
338 Глава 8 Шаблоны внешних API
Результат выполнения сопоставителей — объект Consumer, который содержит
данные, извлеченные из нескольких сервисов.
Теперь посмотрим, как оптимизировать работу сопоставителей с помощью па
кетного выполнения и кэширования.
Оптимизация загрузки с помощью пакетного выполнения
и кэширования
При выполнении запроса GraphQL может вызвать много сопоставителей. Поскольку
каждый сопоставитель выполняется сервером GraphQL по отдельности, существует
риск снижения производительности из-за лишних обращений к сервисам. Возьмем,
к примеру, запрос, который извлекает информацию о клиенте, его заказах и ресто
ранах, которые эти заказы выполнили. Если есть N заказов, в простейшей реали
зации мы сделаем один вызов сервиса Consumer, один вызов сервиса Order History
и затем N вызовов сервиса Restaurant. Несмотря на то что в обычных условиях ядро
GraphQL выполнило бы вызовы сервиса Restaurant параллельно, мы все равно
рискуем получить низкую производительность. К счастью, существует несколько
методик, которые способны помочь в этой ситуации.
Одна из важных оптимизаций — совместное пакетное выполнение и кэширование
на стороне сервера. Пакетное выполнение превращает N обращений к сервису, такому
как Restaurant, в один-единственный вызов, который извлекает набор из N объектов.
Кэширование позволяет повторно использовать результаты предыдущего извлече
ния того же объекта и тем самым избежать необязательного дублирования вызовов.
Сочетание этих двух подходов значительно снижает количество обращений к вну
тренним сервисам.
Сервер GraphQL, базирующийся на NodeJS, может задействовать модуль
Data Loader для реализации пакетного выполнения и кэширования (github.com/
facebook/dataloader). Он объединяет загрузки, которые происходят в рамках одной
итерации рабочего цикла, и вызывает предоставленную вами пакетную функ
цию. Кроме того, он кэширует вызовы, чтобы избежать дублирования загру
зок. В листинге 8.9 показано, как RestaurantServiceProxy может использовать
DataLoader. Метод findRestaurant() применяет этот модуль для загрузки объ
ектов Restaurant.
Листинг 8.9. Использование DataLoader для оптимизации обращения к вызову Restaurant
const DataLoader = require('dataloader');
class RestaurantServiceProxy {
constructorQ {
this.dataLoader = ◄------------
Создаем объект DataLoader, который
использует batchFindRestaurantsO
в качестве пакетной функции
new DataLoader(restaurantIds =>
this.batchFindRestaurants(restaurantlds));
}
findRestaurant(restaurantld) {
Загружаем заданный
экземпляр Restaurant через DataLoader
8.3. Реализация API-шлюза 339
}
return this.dataLoader.load(restaurantld);
batchFindRestaurants(restaurantlds) { <
}
Загружаем набор
объектов Restaurant
}
RestaurantServiceProxy и, следовательно, DataLoader создаются для каждого
запроса, поэтому DataLoader в принципе не может смешать данные разных пользо
вателей.
Теперь посмотрим, как интегрировать ядро GraphQL с веб-фреймворком, чтобы
к нему могли обращаться клиенты.
Интеграция сервера Apollo GraphQL с Express
Сервер Apollo GraphQL выполняет запросы формата GraphQL. Чтобы клиенты
могли к нему обращаться, вы должны интегрировать его с веб-фреймворком. Server
Apollo GraphQL поддерживает несколько веб-фреймворков, включая Express — по
пулярное решение для NodeJS.
В листинге 8.10 показано, как использовать сервер Apollo GraphQL в приложе
нии на основе Express. Ключевой функцией здесь является graphqlExpress, которая
предоставляется модулем apollo-server-express. Она формирует метод-обработ
чик для Express, который выполняет запросы к схеме в формате GraphQL. В этом
примере приложение Express сконфигурировано для перенаправления запросов
к конечным точкам GET /graphql и POST /graphql, принадлежащим обработчику
запросов GraphQL. Кроме того, оно создает контекст GraphQL и помещает в него
прокси-объекты, благодаря чему они становятся доступными для сопоставителей.
Листинг 8.10. Интеграция сервера GraphQL с веб-фреймворком Express
const {graphqlExpress} = require("apollo-server-express");
orders: resolveOrders,
}
type Consumer {
, Определяем сопоставители
const resolvers = { ◄------ 1
Query: {
}
}
Связываем «ему с сопоставителями,
чтобы создать исполняемую схему
const schema = makeExecutableSchema({ typeDefs, resolvers }); ◄-
340 Глава 8 • Шаблоны внешних API
const арр = express();
Внедряем репозитории в контекст,
чтобы сделать их доступными
для сопоставителей
function makeContextWithDependencies(req) {
const orderServiceProxy = new OrderServiceProxy();
const consumerServiceProxy = new ConsumerServiceProxy();
const restaurantServiceProxy = new RestaurantServiceProxy();
}
return {orderServiceProxy, consumerServiceProxy,
restaurantServiceProxy, ...};
Обработчик Express, который шлет
GraphQL-запросы к исполняемой схеме
function makeGraphQLHandler() {
return graphqlExpress(req => {
return {schema: schema, context: makeContextWithDependencies(req)}
});
}
app.post('/graphql', bodyParser.json(), makeGraphQLHandler()); ◄--------------
app.get('/graphql’, makeGraphQLHandler());
Направляем конечные точки POST /graphql
app. listen (PORT); и GET/graphql к серверу GraphQL
В этом примере не учитываются такие аспекты, как безопасность, но их было бы
несложно реализовать. API-шлюз мог бы, к примеру, аутентифицировать пользо
вателей с помощью Passport — фреймворка для обеспечения безопасности в NodeJS
(см. главу И). Функция makeContextWithDependencies() передавала бы информацию
о пользователе каждому конструктору, чтобы тот переслал ее сервисам.
Теперь поговорим о том, как клиент может обратиться к серверу, чтобы выпол
нить GraphQL-запросы.
Написание клиента GraphQL
Клиентское приложение может обращаться к серверу GraphQL несколькими спо
собами. Поскольку сервер GraphQL имеет API на основе HTTP, клиент спосо
бен выполнять запросы с помощью HTTP-библиотеки — например, GET http://
localhost:3000/graphql?query={orders(consumerld:1){orderld,restaurant{id}}}.
Более простое решение — использование клиентской библиотеки GraphQL, которая
берет на себя корректное форматирование запросов и обычно предоставляет такие
возможности, как кэширование на клиентской стороне.
В листинге 8.11 показан класс FtgoGraphQLClient, который представляет собой
простой клиент для приложения FTGO на основе GraphQL. Его конструктор соз
дает объект ApolloClient, который входит в состав клиентской библиотеки Apollo
GraphQL. Класс FtgoGraphQLClient определяет метод findconsumer(), который за
действует этот клиент для извлечения имени заказчика.
Класс FtgoGraphQLClient может содержать различные методы запросов, такие
как findConsumer(). Каждый из них выполняет запрос, который извлекает именно
те данные, которые нужны клиенту.
Резюме 341
Листинг 8.11. Использование клиента Apollo GraphQL для выполнения запросов
class FtgoGraphQLClient {
constructor^...) {
this.client = new ApolloClient({ ... });
}
findConsumer(consumerld) {
return this.client.query({
variables: { cid: consumerld}, <
query: gql'
query foo($cid : Int!) { <
I Предоставляем значение для $dd
| Определяем $dd как переменную типа Int
consumer(consumerld: $cid)
id
firstName
Присваиваем $dd параметру
запроса consumerid
lastName
}
} \
})
}
}
В этом разделе мы едва затронули возможности GraphQL. Надеюсь, мне удалось
продемонстрировать, что эта технология является привлекательной альтернативой
более традиционному API-шлюзу, основанному на REST. Таким образом, при раз
работке своих API-шлюзов вы должны рассматривать GraphQL в качестве одного
из вариантов.
Резюме
□ Обычно внешние клиенты приложения обращаются к его сервисам через API-
шлюз. API-шлюз предоставляет каждому клиенту отдельный API. Он отвечает
за маршрутизацию запросов, объединение API, преобразование протоколов
и реализацию таких граничных функций, как аутентификация.
□ У вашего приложения может быть один или несколько API-шлюзов, по одному
для каждого типа клиентов. В последнем случае применяется шаблон BFF.
Основное его преимущество в том, что он делает команды клиентской разработки
более автономными, поскольку каждая из них пишет, развертывает и админи
стрирует собственный API-шлюз.
□ Существует целый ряд технологий, используемых для реализации API-шлюза,
включая готовые решения. Но вы можете разработать собственный API-шлюз
с помощью фреймворка.
□ Spring Cloud Gateway — это простой в применении фреймворк, который хо
рошо подходит для разработки API-шлюзов. Для маршрутизации запросов он
может задействовать любые их атрибуты, включая метод и путь. Он может на
правлять запросы напрямую к внутренним сервисам или к пользовательскому
342 Глава 8 • Шаблоны внешних API
методу-обработчику. Этот проект основан на масштабируемых реактивных
фреймворках Spring Framework 5 и Project Reactor. Вы можете писать пользова
тельские обработчики запросов в реактивном стиле на основе таких абстракций,
как Mono из состава Project Reactor.
□ Еще одна отличная основа для разработки API-шлюзов — фреймворк GraphQL,
предоставляющий графовый язык запросов. Для описания модели серверных
данных и запросов, которые она поддерживает, используется схема в виде
графа. Она накладывается на ваши сервисы путем написания функций сопо
ставления, которые извлекают данные. Клиенты, основанные на GraphQL, об
ращаются к схеме, указывая серверу, какие именно данные он должен вернуть.
В итоге API-шлюз, построенный по этой технологии, поддерживает разные
виды клиентов.
Тестирование
микросервисов, часть 1
В FTGO, как и во многих других организациях, подход к тестированию традици
онный. Тестирование — это процесс, проводимый в основном после разработки.
Разработчики передают написанный код коллегам из отдела обеспечения качества,
которые проверяют, работает ли программный продукт так, как задумано. Большая
часть тестирования выполняется вручную. К сожалению, такой подход неприемлем
по двум причинам.
□ Ручное тестирование чрезвычайно неэффективно. Никогда не просите чело
века сделать то, что у компьютера получается намного лучше. По сравнению
с компьютерами люди работают с низкой производительностью и не способны
делать это круглосуточно. Если вы полагаетесь на ручное тестирование, забудьте
о быстрой и безопасной доставке программного обеспечения. Написание автома
тических тестов — это очень важно.
□ Тестирование производится на слишком позднем этапе процесса доставки. Безуслов
но, тестирование уже написанного приложения имеет право на существование,
но, как показывает опыт, этого недостаточно. Написание автоматических тестов
344 Глава 9 • Тестирование микросервисов, часть 1
намного лучше сделать частью разработки. Это повысит продуктивность, по
скольку разработчики смогут видеть результаты тестирования во время редак
тирования кода.
В этом отношении FTGO — типичная организация. Отчет Sauce Labs Testing
Trends за 2018 год рисует довольно мрачную картину состояния автоматизации
тестов (saucelabs.com/resources/white-papers/testing-trends-for-2018). В нем говорится, что
в основном автоматизированными являются лишь 26 % организаций, а полностью
автоматизированными — жалкие 3 %!
Зависимость от ручных тестов не связана с нехваткой инструментария и фрейм
ворков. Например, J Unit — популярный фреймворк тестирования для Java — был
выпущен еще в 1998 году. Плачевное состояние автоматических тестов вызвано
культурными аспектами: «тестированием должен заниматься отдел обеспечения
качества», «у разработчиков есть более важные задачи» и т. д. Ситуацию не улучшает
и то, что создание набора быстрых, эффективных и легкоподдерживаемых тестов
требует больших усилий. К тому же крупное монолитное приложение, как правило,
очень сложно протестировать.
Как упоминалось в главе 2, ключевым фактором при выборе микросервисной ар
хитектуры является улучшение тестируемости. В то же время из-за своей сложности
микросервисный подход требует написания автоматических тестов. Более того, не
которые аспекты тестирования микросервисов трудно реализовать, ведь необходимо
убедиться в том, что они могут корректно взаимодействовать между собой, но при
этом минимизировать количество медленных, сложных и ненадежных сквозных
тестов, которые требуют запуска множества сервисов.
Это первая из двух глав, посвященных тестированию. Считайте ее введением.
В главе 10 рассматриваются более продвинутые концепции. Обе они довольно
длинные, но в них вы сможете найти идеи и методики тестирования, необходимые
для разработки современного программного обеспечения в целом и микросервисной
архитектуры в частности.
Я начну главу с описания эффективных стратегий тестирования микросервисных
приложений. Эти стратегии дадут вам уверенность в работоспособности вашего
кода, минимизируя при этом сложность тестов и время их выполнения. После этого
я продемонстрирую написание определенного вида тестов для сервисов, называемых
модульными. Другие типы тестов — интеграционные, компонентные и сквозные —
будут описаны в главе 10.
Поговорим о стратегиях тестирования микросервисов.
9.1. Стратегии тестирования микросервисных архитектур 345
9.1. Стратегии тестирования микросервисных
архитектур
Представьте, что вы вносите изменение в сервис Order приложения FTGO. Вслед за
этим будет естественно запустить измененный код и убедиться в его корректной
работе. Вы можете протестировать изменение вручную. Сначала нужно запустить
сервис Order и все его зависимости, включая инфраструктурные компоненты напо
добие БД и другие сервисы приложения. Затем, чтобы проверить сервис, вы должны
обратиться к нему либо через его API, либо через пользовательский интерфейс при
ложения. Это медленный ручной способ тестирования кода.
Куда более подходящим выбором будет написание автоматических тестов, кото
рые можно запускать во время разработки. Процесс написания приложения должен
выглядеть так: отредактировать код, запустить тесты (в идеале одним нажатием кла
виши), повторить. Если тесты выполняются быстро, через несколько секунд станет
ясно, работает ли измененный код. Но как сделать тесты быстрыми? Как узнать,
требуется ли более комплексное тестирование? Ответы на эти вопросы я даю в этом
и следующем разделах.
Данный раздел начинается с обзора важных концепций автоматического тести
рования. Вы увидите, какие задачи оно решает, и познакомитесь со структурой ти
пичного теста. Я опишу различные типы тестов, которые вам нужно будет создавать.
Также будет представлена пирамида тестов, которая служит полезным руководством
относительно того, какие участки кода больше всего нуждаются в тестировании.
После знакомства с основными концепциями мы поговорим о стратегиях тестиро
вания микросервисов и присущих им конкретных трудностях. Вы познакомитесь
с методиками, позволяющими писать более простые, быстрые, но в то же время
эффективные тесты для микросервисной архитектуры.
Итак, начнем с концепций тестирования.
9.1.1. Обзор методик тестирования
В этой главе основное внимание уделяется автоматическим тестам. Упоминая
здесь какие-либо тесты, я подразумеваю, что они автоматические. «Википедия»
дает следующее определение тестового случая’. «Тестовый случай — это формально
346 Глава 9 • Тестирование микросервисов, часть 1
описанный алгоритм тестирования программы, специально созданный для опреде
ления возникновения в программе определенной ситуации, определенных выходных
данных»'.
Иными словами, целью теста (рис. 9.1) является проверка поведения тестиру
емой системы. Под системой здесь подразумевается элемент программного обеспе
чения, к которому применяется тест. Это может быть всего лишь класс, или целое
приложение, или нечто среднее, например набор классов или отдельный сервис.
Коллекция взаимосвязанных тестов формирует тестовый набор.
Рис. 9.1. Цель теста состоит в проверке поведения тестируемой системы. Система может быть
всего лишь классом или целым приложением
Сначала мы рассмотрим концепцию автоматических тестов. Затем обсудим
тесты разных типов, которые вам придется писать. После этого будет представлена
пирамида тестов, описывающая относительные пропорции тестов, необходимых
в том или ином случае.
Написание автоматических тестов
Автоматические тесты обычно пишут, используя специальный фреймворк. Напри
мер, JUnit — популярный фреймворк тестирования для Java. Структура автомати
ческого теста показана на рис. 9.2. Каждый тест реализуется в виде метода, который
принадлежит тестовому классу.
Типичный автоматический тест состоит из четырех этапов (xunitpatterns.com/
Four%20Phase%20Test.html).
1. Подготовка — инициализирует среду тестирования, состоящую из самой си
стемы и ее зависимостей, приводя ее в нужное состояние. Например, создает
тестируемый класс и приводит его в состояние, необходимое для демонстрации
желаемого поведения.
2. Выполнение — запускает тестируемую систему, например вызов метода из тести
руемого класса.
ru.wikipedia.org/wiki/ВарианТ-Тестирования.
9.1. Стратегии тестирования микросервисных архитектур 347
3. Проверка — делает выводы о результате выполнения и состоянии тестируемой
системы. Например, проверяет значение, возвращаемое методом, и новое состоя
ние тестируемого класса.
4. Очистка - удаляет среду тестирования, если это необходимо. Многие тесты про
пускают этот этап, но, например, при тестировании БД иногда нужно откатить
транзакции, инициированные на этапе подготовки.
Рис. 9.2. Каждый автоматический тест реализуется тестовым методом, который принадлежит
тестовому классу. Тест состоит из четырех этапов: подготовки — подготовки среды тестирования
(то есть всего, что необходимо для выполнения теста); выполнения — запуска тестируемой
системы; проверки — оценки результатов теста; очистки — удаления среды тестирования
Чтобы уменьшить дублирование кода и упростить тесты, в тестовый класс иногда
добавляют подготовительные методы, которые выполняются перед вызовом самого
теста, и методы очистки, реализуемые в самом конце. Тестовый набор — это перечень
тестовых классов. Для их запуска используется средство выполнения тестов.
Тестирование с помощью макетов и заглушек
Тестируемая система часто имеет зависимости, которые могут осложнить и замед
лить ваши тесты. Например, класс Ordercontroller обращается к сервису Order,
который так или иначе зависит от многих других прикладных и инфраструктурных
сервисов. Тестирование класса Ordercontroller путем запуска значительной части
приложения было бы непрактичным. Нам нужно как-то изолировать свои тесты.
Решение, показанное на рис. 9.3, состоит в замене зависимостей тестируемой систе
мы дублерами. Дублер — это объект, который симулирует поведение зависимости.
Существует два вида дублеров: заглушки (stubs) и макеты (mocks). Эти термины
часто считают взаимозаменяемыми, хотя они немного различаются. Заглушка — это
дублер, который возвращает значения тестируемой системе. Макет — это дублер,
используемый тестом для проверки того, что тестируемая система корректно вы
зывает свои зависимости. Во многих случаях макет является заглушкой.
348 Глава 9 • Тестирование микросервисов, часть 1
Рис. 9.3. Замена зависимости дублером, который позволяет тестировать систему в изоляции.
Тест получается более простым и быстрым
Позже в этой главе вы увидите примеры применения дублеров. Так, в подраз
деле 9.2.5 будет показано, как протестировать класс Ordercontroller в изоляции,
задействуя дублер класса OrderService. В этом примере дублер OrderService ре
ализуется с помощью Mockito — популярного фреймворка для создания макетов
объектов в Java. В главе 10 вы увидите, как протестировать сервис Order, используя
дублеры тех сервисов, к которым он обращается. Эти дублеры будут отвечать на
командные сообщения, отправляемые сервисом Order.
Теперь рассмотрим разные типы тестов.
Типы тестов
Существует множество типов тестов. Некоторые из них, такие как тесты произ
водительности и удобства использования, позволяют убедиться в том, что при
ложение отвечает требованиям к качеству обслуживания. В этой главе основное
внимание уделяется автоматическим тестам, которые проверяют функциональные
аспекты приложения или сервисов. Вы узнаете, как пишутся тесты четырех разных
типов:
□ модульные тесты, которые тестируют небольшую часть сервиса, такую как класс;
□ интеграционные тесты, которые проверяют, может ли сервис взаимодействовать
с инфраструктурными компонентами, такими как базы данных и другие сервисы
приложения;
□ компонентные тесты — приемочные тесты для отдельного сервиса;
□ сквозные тесты — приемочные тесты для целого приложения.
Они различаются в основном охватом. На одном конце спектра находятся мо
дульные тесты, которые проверяют поведение наименьшего значимого элемента
9.1. Стратегии тестирования микросервисных архитектур 349
программы. В объектно-ориентированных языках, таких как Java, это класс. Их про
тивоположность — сквозные тесты, проверяющие поведение целого приложения.
Посередине находятся компонентные тесты, относящиеся к отдельным сервисам.
Интеграционные тесты, как вы увидите в следующей главе, имеют относительно
небольшой охват, но они сложнее, чем обычные модульные тесты. Охват — это
лишь один из способов охарактеризовать тест. Еще одна характеристика — тестовый
квадрант.
Использование тестового квадранта
для классифицирования тестов
Хороший способ классифицировать тесты — тестовый квадрант, предложенный
Брайаном Мариком (Brian Marick) (www.exampler.com/old-blog/2003/08/21/#agile-testing-
project-1). Он группирует тесты по двум основаниям (рис. 9.4).
□ К чему относится тест — к бизнесу или технологиям. Бизнес-тест описывается
в терминологии специалиста проблемной области, тогда как для описания тех
нологического теста используется терминология разработчиков и реализации.
□ Какова цель теста — помочь с написанием кода или дать оценку приложению.
Разработчики применяют тесты, которые помогают им в ежедневной работе. Тесты,
оценивающие приложение, нужны для определения проблемных участков.
Тестовый квадрант определяет четыре категории тестов:
□ Q1 — помощь в программировании с ориентацией на технологии — модульные
и интеграционные тесты;
□ Q2 — помощь в программировании с ориентацией на бизнес — компонентные
и сквозные тесты;
350 Глава 9 • Тестирование микросервисов, часть 1
□ Q3 — оценка приложения с точки зрения бизнеса — проверка удобства исполь
зования и исследовательское тестирование;
□ Q4 — оценка приложения с точки зрения технологий — нефункциональное при
емочное тестирование, такое как проверка производительности.
Помимо тестового квадранта, существуют и другие способы организации тестов.
Например, пирамида тестов помогает определить, сколько тестов каждого типа
следует написать.
Ориентация на бизнес
Q2 Автоматические
Функциональные/
приемочные тесты
Q3 Ручные
Исследовательское
тестирование,
тестирование удобства
использования
Q1 Автоматические
Модульные,
интеграционные,
компонентные
Q4 Ручные/
автоматические
Нефункциональные
приемочные тесты:
производительность
и др.
Ориентация на технологии
Рис. 9.4. Тестовый квадрант классифицирует тесты по двум основаниям. Первое — это ориентация
теста на бизнес или технологии. Второе — назначение теста: помощь в программировании
или оценка приложения
Применение пирамиды тестов
как средства приоритизации тестирования
Чтобы удостовериться, что наше приложение работает, мы должны написать разного
рода тесты. Но проблема в том, что с увеличением охвата теста растут его сложность
и время выполнения. Кроме того, чем шире охват теста и чем больше составных
элементов он в себя включает, тем менее надежным становится. Ненадежные тесты
не намного лучше, чем их отсутствие, ведь, если тесту нельзя доверять, его сбои,
скорее всего, будут игнорироваться.
На одном конце спектра располагаются модульные тесты для отдельных классов.
Они надежны, просты в написании и быстро выполняются. На противоположном
конце находятся сквозные тесты для всего приложения. Они медленные, их сложно
9.1. Стратегии тестирования микросервисных архитектур 351
писать, а из-за сложности они часто оказываются ненадежными. Поскольку наш
бюджет на разработку и тестирование ограничен, мы хотим сосредоточиться на на
писании тестов с небольшим охватом, не ставя при этом под угрозу эффективность
тестового набора.
Хорошим подспорьем в этом может стать пирамида тестов (рис. 9.5) (martinfbwler.com/
bliki/TestPyramid.html). В ее основании лежат быстрые, простые и надежные тесты.
Сквозные тесты, отличающиеся низкой скоростью, высокой сложностью и нена
дежностью, расположены на вершине. Пирамида тестов описывает относительные
пропорции каждого типа тестирования. Она похожа на пирамиду питания, которую
публикует Министерство сельского хозяйства США (ru.wlkipedia.org/wiki/nnpaMHfla_nn-
тания), но, честно говоря, приносит больше пользы и вызывает меньше споров.
Рис. 9.5. Пирамида тестов описывает относительные пропорции типов тестов, которые нужно
написать. Продвигаясь вверх, вы должны писать все меньше и меньше тестов
Суть этого подхода заключается в том, что с продвижением вверх по пирамиде
мы должны писать все меньше и меньше тестов. Модульных тестов должно быть
много, а сквозных — мало.
В этой главе я рассмотрю стратегию, которая делает акцент на тестировании эле
ментов сервиса. Она минимизирует даже количество компонентных тестов, которые
проверяют сервис целиком.
Тестирование отдельных микросервисов наподобие Consumer, не зависящих от
других сервисов, не составляет труда. Но как насчет таких сервисов, как Order, у ко
торых есть многочисленные зависимости? Как можно быть уверенным в том, что
приложение в целом работоспособно? Это ключевые вопросы, которые относятся
к тестированию приложений с микросервисной архитектурой. Сложность тестиро
вания — характеристика не столько отдельных сервисов, сколько взаимодействия
между ними. Давайте посмотрим, как подойти к этой проблеме.
352 Глава 9 • Тестирование микросервисов, часть 1
9.1.2. Трудности тестирования микросервисов
Межпроцессное взаимодействие играет намного более важную роль в микросервис
ной архитектуре, чем в монолитном приложении. Монолитный код может общаться
с несколькими внешними клиентами и сервисами. Например, монолитная версия
FTGO использует несколько сторонних веб-сервисов со стабильными API: Stripe
для платежей, Twilio для обмена сообщениями и Amazon SES — для почты. Любое
взаимодействие между внутренними модулями происходит на уровне языка про
граммирования. Фактически IPC находится на границе приложения.
Для сравнения: в микросервисной архитектуре межпроцессное взаимодействие
играет одну из ключевых ролей. Микросервисное приложение — распределенная
система. Разные команды заняты разработкой своих сервисов и развитием их API.
Очень важно, чтобы разработчики сервиса писали тесты, которые проверяют, как
он взаимодействует со своими зависимостями и клиентами.
Как говорилось в главе 3, сервисы могут общаться между собой, применяя разно
образные стили и механизмы IPC. В некоторых случаях используется стиль «запрос/
ответ», который реализуется с помощью таких синхронных протоколов, как REST
или gRPC. Сервисы могут общаться также в стиле «запрос/асинхронный ответ»
или «издатель/подписчик», обмениваясь асинхронными сообщениями. Например,
на рис. 9.6 показано, как взаимодействуют некоторые сервисы приложения FTGO.
Каждая стрелка ведет от потребителя к отправителю.
Стрелка указывает в направлении зависимости — от потребителя API к сервису,
который его предоставляет. Ожидания потребителя относительно API зависят от
природы взаимодействия:
□ REST-клиент -> сервис. API-шлюз направляет запросы к сервисам и занимается
объединением API.
□ Потребитель доменных событий -> издатель. Сервис Order History потребляет
события, публикуемые сервисом Order.
□ Сторона, запрашивающая командные сообщения отвечающая сторона. Сервис
Order шлет командные сообщения различным сервисам и потребляет их ответы.
Каждое взаимодействие между двумя сервисами может быть представлено в виде
соглашения или контракта. Например, сервисы Order History и Order должны со
гласовать структуру сообщений с событиями и канал, в который те будут публи
коваться. Точно так же API-шлюз и сервисы должны согласовать конечные точки
REST API. К тому же сервис Order и все другие сервисы, с которыми он общается
с помощью асинхронных запросов и ответов, должны выбрать командный канал,
а также формат команд и ответов.
Вы, как разработчик сервиса, должны быть уверены в стабильности API, кото
рые потребляете. По этой же причине не следует вносить ломающие изменения
в API собственного сервиса. Например, если вы работаете над сервисом Order, то
должны быть уверены в том, что разработчики его зависимостей, таких как сервисы
Consumer и Kitchen, не нарушат совместимость их API с вашим кодом. Точно так
же вы должны следить за тем, чтобы изменения в API сервиса Order не повлияли на
совместимость с API-шлюзом или сервисом Order History.
9.1. Стратегии тестирования микросервисных архитектур 353
Рис. 9.6. Часть межсервисного взаимодействия в приложении FTGO
Чтобы проверить, способны ли два сервиса общаться между собой, можно об
ратиться к API, который инициирует взаимодействие, и убедиться в том, что он
возвращает ожидаемый результат. Это точно поможет выявить проблемы с интегра
цией, но это, в сущности, сквозной тест. Скорее всего, ему придется запускать мно
жество других переходных зависимостей этих сервисов. Возможно, он также должен
будет вызывать сложные высокоуровневые функции, такие как бизнес-логика, хотя
его цель — проверка относительно низкоуровневого механизма IPC. Лучше всего
избегать написания подобных сквозных тестов. Нам нужны более быстрые, простые
и надежные тесты, которые в идеале проверяют работу сервисов в изоляции. В каче
стве решения можно воспользоваться так называемым тестированием контрактов
с расчетом на потребителя.
354 Глава 9 • Тестирование микросервисов, часть 1
Тестирование контрактов с расчетом на потребителя
Представьте, что вы являетесь членом команды, которая занимается разработкой
API-шлюза, описанного в главе 8. Объект шлюза OrderServiceProxy обращается
к различным конечным точкам REST, включая GET /orders/{orderld}. В этом
случае крайне важно иметь тесты, которые проверяют согласованность API между
API-шлюзом и сервисом Order. В терминологии тестирования потребительских
контрактов между этими двумя сервисами имеется связь «потребитель — провай
дер». Потребителем выступает API-шлюз, а провайдером — сервис Order. Проверка
потребительского контракта — это интеграционный тест для провайдера, такого как
сервис Order, он позволяет убедиться в том, что API провайдера отвечает ожиданиям
потребителя, такого как API-шлюз.
Тестирование потребительского контракта сосредоточено на проверке того, что
API провайдера по своей форме отвечает ожиданиям потребителя. В данном случае
оно позволяет убедиться в том, что провайдер реализует конечную точку REST,
которая:
□ имеет нужные HTTP-метод и путь;
□ принимает нужные заголовки, если таковые имеются;
□ принимает тело запроса, если оно имеется;
□ возвращает ответ с ожидаемыми кодом состояния, заголовками и телом.
Следует помнить, что тесты контрактов не занимаются тщательной проверкой
бизнес-логики провайдера. За это отвечают модульные тесты. Позже вы увидите, что
в контексте REST API тесты потребительских контрактов на самом деле являются
тестами макетов контроллеров.
Команда, разрабатывающая потребительский код, пишет набор тестов для кон
трактов и делает его частью тестового набора провайдера, например, через запрос на
принятие изменений. Разработчики других сервисов, которые обращаются к сервису
Order, тоже вносят свой вклад в этот набор (рис. 9.7). Каждый набор тестов будет
проверять те аспекты API Order, которые относятся к тому или иному потребите
лю. Например, тестовый набор для сервиса Order History проверяет, публикует ли
сервис Order ожидаемые события.
Эти тестовые наборы выполняются в процессе развертывания сервиса Order.
Если проверка потребительского контракта завершается неудачно, разработчики
провайдера делают вывод о том, что они внесли ломающее изменение в API. Им сле
дует либо исправить API, либо связаться с командой потребительской стороны.
9.1. Стратегии тестирования микросервисных архитектур 355
Рис. 9.7. Команда каждого сервиса, который потребляет API сервиса Order, предоставляет
тестовый набор контрактов. Этот набор проверяет, соответствует ли API ожиданиям потребителей.
Данные тесты в сочетании с тестовыми наборами других команд выполняются в рамках процесса
развертывания сервиса Order
Тесты контрактов с расчетом на потребителя обычно применяют тестирование
по примеру. Взаимодействие между потребителем и провайдером определяется
набором примеров, которые называются контрактами. Каждый контракт состоит
из примеров сообщений, обмен которыми происходит во время взаимодействия.
Скажем, контракт для REST API состоит из примеров HTTP-запроса и ответа.
На первый взгляд может показаться, что взаимодействие лучше описывать в виде
схем в формате Open API или JSON. Но, как оказалось, схемы не очень подходят для
написания тестов. Они могут помочь с проверкой ответа, но тест все равно должен
обратиться к провайдеру с примером запроса.
Более того, потребительским тестам нужны еще и примеры ответов. Несмотря на
то что основной задачей данного подхода является проверка провайдера, контракты
также проверяют, соответствует ли им потребитель. Например, потребительский
контракт для REST-клиента конфигурирует заглушку сервиса, которая проверяет,
совпадает ли HTTP-запрос с запросом контракта, и возвращает обратно его НТТР-
ответ. Тестирование обеих сторон взаимодействия позволяет убедиться в том, что
потребитель и провайдер согласовали API, Позже вы увидите примеры написания
356 Глава 9 • Тестирование микросервисов, часть 1
подобного рода тестов, но сначала посмотрим, как выполнять тестирование потре
бительских контрактов с помощью Spring Cloud Contract.
Тестирование сервисов с помощью Spring Cloud Contract
Существует два популярных фреймворка для тестирования контрактов: Spring
Cloud Contract (https://spring.io/projects/spnng-cloud-contract), который позволяет те
стировать потребительские контракты в приложениях, основанных на Spring, и се
мейство фреймворков Pact (github.com/pact-foundation) с поддержкой разных языков.
Приложение FTGO использует фреймворк Spring, поэтому в данной главе я покажу,
как работать со Spring Cloud Contract. Для написания контрактов эта технология
предоставляет проблемно-ориентированный язык (DSL) в стиле Groovy. Каждый
контракт — это конкретный пример взаимодействия между потребителем и про
вайдером, такой как HTTP-запрос и ответ. Код Spring Cloud Contract генерирует
тесты контрактов для провайдера. Он также настраивает макеты (например, макет
HTTP-сервера) для потребительских интеграционных тестов.
Представьте, к примеру, что вы работаете над API-шлюзом и хотите написать тест
потребительского контракта для сервиса Order. Этот процесс (рис. 9.8) подразумевает
взаимодействие с командами, отвечающими за этот сервис. Вы пишете контракты,
которые определяют, как API-шлюз общается с сервисом Order. Команда сервиса
Order использует эти контракты для тестирования своего сервиса, а вы с их помощью
проверяете свой API-шлюз. Последовательность шагов приведена далее.
1. Вы пишете один или несколько контрактов (пример приведен в листинге 9.1).
Каждый контракт состоит из HTTP-запроса, который API-шлюз может послать
сервису Order, и ожидаемого HTTP-ответа. Эти контракты вы передаете команде
сервиса Order (возможно, посредством запроса на принятие изменений в Git).
2. Команда сервиса Order тестирует его с помощью тестов, код которых сгенериро
ван из потребительских контрактов с помощью Spring Cloud Contract.
3. Команда сервиса Order публикует полученные контракты в репозиторий Maven.
4. Вы используете опубликованные контракты, чтобы написать тесты для API-
шлюза.
Поскольку вы тестируете API-шлюз с помощью опубликованных контрактов, то
можете быть уверены в его совместимости с развернутым сервисом Order.
Контракты — это ключевая часть стратегии тестирования. В листинге 9.1 по
казан пример контракта для Spring Cloud Contract. Он состоит из НТТР-запроса
и НТТР-ответа.
9.1. Стратегии тестирования микросервисных архитектур 357
Рис. 9.8. Команда API-шлюза пишет контракты. Команда сервиса Order тестирует его с помощью
этих контрактов и публикует их в репозиторий. Команда API-шлюза применяет опубликованные
контракты для тестирования своего кода
Листинг 9.1. Контракт, описывающий то, как API-шлюз обращается к сервису Order
org.springframework.cloud.contract.spec.Contract.make {
re4Umethid 'GET' I Метод и путь НИР-запроса
url •/orders/1223232'
}
response {
status 200
headers {
header('Content-Type': 'application/json;charset=UTF-8’)
}
body("{ ... }")
Код состояния, заголовки
и тело НИР-ответа
}
}
Роль запрашивающего элемента играет HTTP-запрос для конечной точки REST
GET /orders/{orderld}. Ответный элемент представляет собой HTTP-ответ, опи
сывающий заказ, ожидаемый API-шлюзом. Контракты на языке Groovy являются
частью кодовой базы провайдера. Каждая потребительская команда создает кон
тракты, которые описывают взаимодействие ее сервиса с провайдером, и передает их
команде провайдера (возможно, через запрос принятия изменений в Git). Команда
провайдера упаковывает контракты в архив JAR и публикует их в репозитории
Maven. Тесты на стороне потребителя загружают этот архив из репозитория.
358 Глава 9 • Тестирование микросервисов, часть 1
Все запросы и ответы контракта используются не только для тестирования, но
и в качестве спецификации ожидаемого поведения. В тестах на стороне потребителя
контракт применяется для конфигурации заглушки, аналогичной объекту-макету
в Mockito, и симулирует поведение сервиса Order. Это позволяет тестировать API-
шлюз без запуска этого сервиса. На стороне провайдера сгенерированный тестовый
класс шлет провайдеру запрос контракта и проверяет, совпадает ли его ответ с отве
том контракта. Подробно о Spring Cloud Contract мы поговорим в следующей главе,
а пока что посмотрим, как использовать тестирование потребительских контрактов
для API обмена сообщениями.
Тесты потребительских контрактов для API обмена сообщениями
REST-клиент — это не единственный вид потребителей с определенными ожида
ниями относительно API провайдера. Потребителями могут выступать также сер
висы, которые подписываются на доменные события и взаимодействуют с помощью
асинхронных запросов/ответов. Они обращаются к асинхронным API других сер
висов, делая предположения о природе этих API. Для них тоже нужно писать тесты
потребительских контрактов.
Spring Cloud Contract также поддерживает тестирование взаимодействия на
основе обмена сообщениями. Структура контракта и то, как он применяется в тестах,
зависит от типа взаимодействия. Контракт для публикации доменных событий со
стоит из примера доменного события. В ходе тестирования провайдер генерирует
событие и проверяет, совпадает ли оно с событием контракта. Потребительский тест
проверяет, может ли потребитель обработать событие. Пример такого теста будет
представлен в следующей главе.
Контракт для асинхронного взаимодействия в стиле «запрос/ответ» похож на
HTTP-контракты. Он состоит из двух сообщений: с запросом и ответом. Тест про
вайдера шлет запрос контракта интерфейсу (API) и проверяет, совпадает ли полу
ченный ответ с ответом контракта. Потребительский тест использует контракт,
чтобы сконфигурировать заглушку для подписчика, которая перехватывает запрос
контракта и возвращает указанный ответ. Пример такого теста описывается в сле
дующей главе. Здесь же мы рассмотрим процесс развертывания, в рамках которого
выполняются эти и другие тесты.
9.1.3. Процесс развертывания
У каждого сервиса есть свой процесс развертывания. В книге Джеза Хамбла (Jez
Humble) Continuous Delivery (Addison-Wesley, 2010)1 процесс развертывания опи
сывается как автоматическая доставка кода из компьютера разработчика в промыш
ленную среду. Он состоит из поэтапного выполнения тестов, вслед за которым про
исходит выпуск или развертывание сервиса (рис. 9.9). В идеале этот процесс должен
быть полностью автоматизированным, но в реальности он может требовать ручного
вмешательства. Процесс развертывания часто реализуется с помощью С1-сервера
(Continuous Integration — непрерывное развертывание), такого как Jenkins.
1 Хамбл Д. Непрерывное развертывание. — М.: Вильямс, 2011.
9.1. Стратегии тестирования микросервисных архитектур 359
360 Глава 9 • Тестирование микросервисов, часть 1
По мере прохождения кода через процесс развертывания наборы тестов все более
тщательно тестируют его в среде, приближенной к промышленной. Одновременно
время выполнения каждого тестового набора обычно увеличивается. Основной
смысл процедуры состоит в том, чтобы как можно скорее сообщить о непройденных
тестах.
Процесс развертывания (см. рис. 9.9) состоит из следующих этапов.
□ Этап, предшествующий фиксации, — выполняет модульные тесты. Запускается
разработчиком перед фиксацией изменений.
□ Этап фиксации — компилирует сервис, выполняет модульные тесты и произво
дит статический анализ кода.
□ Интеграционный этап — выполняет интеграционные тесты.
□ Компонентный этап — выполняет компонентные тесты для сервиса.
□ Этап развертывания — развертывает сервис в промышленной среде.
CI-сервер запускает этап фиксации, когда разработчик фиксирует изменение.
Он выполняется чрезвычайно быстро, чтобы сразу предоставить сведения о фик
сации. Дальнейшие этапы протекают дольше и предоставляют информацию не так
быстро. Если все тесты пройдены, на заключительном этапе код развертывается
в промышленную среду.
В этом примере автоматизирован весь процесс, от фиксации до развертывания.
Однако в некоторых ситуациях требуется вмешательство человека. Например, вам
может понадобиться этап ручного тестирования в предпромышленной среде. В этом
сценарии код переходит на следующий этап, когда тестировщик отмечает успешное
тестирование нажатием кнопки. Это может быть также выпуск новой версии сервиса
в рамках процесса развертывания. Позже выпущенные сервисы будут упакованы
и в качестве готового продукта отправлены заказчикам.
Теперь вы знаете, как организован процесс развертывания и на каких этапах вы
полняются разные типы тестов. Переместимся в самый низ пирамиды тестирования
и посмотрим, как пишут модульные тесты для сервиса.
9.2. Написание модульных тестов
для сервиса
Представьте, что вам нужно написать тест, который проверяет, вычисляет ли
сервис Order приложения FTGO корректную промежуточную стоимость заказа.
Код вашего теста может запустить сервис Order, обратиться к его REST API, что
бы создать заказ, и проверить, содержит ли HTTP-ответ ожидаемые значения.
Однако при этом тест получится не только сложным, но и медленным. Если он
выполняется на этапе компиляции класса Order, вы будете тратить много времени
в ожидании его завершения. Написание модульных тестов для класса Order — куда
более продуктивный подход.
9.2. Написание модульных тестов для сервиса 361
Как видно на рис. 9.10, модульные тесты находятся на самом нижнем уровне пи
рамиды тестирования. Они ориентированы на технологии и могут использоваться
в разработке. Модульный тест позволяет убедиться в корректной работе модуля,
который представляет собой очень маленькую часть сервиса. Обычно в качестве
модуля выступает класс, поэтому модульное тестирование проверяет, ведет ли он
себя так, как от него ожидается.
Рис. 9.10. Модульные тесты лежат в основании пирамиды. Они быстрые, простые в написании
и надежные. Изолированный модульный тест тестирует отдельно взятый класс, задействуя макеты
и заглушки вместо его зависимостей. Общительный модульный тест тестирует класс вместе с его
зависимостями
Существует два типа модульных тестов (martinfowler.com/bliki/UnitTest.html):
□ изолированный — тестирует отдельно взятый класс, заменяя его зависимости
объектами-макетами;
□ общительный — тестирует класс и его зависимости.
Тип теста, который следует использовать, зависит от назначения класса и его
роли в архитектуре. Шестигранная архитектура типичного сервиса и типы модуль
ных тестов, применяемые для определенного вида классов, показаны на рис. 9.11.
Классы контроллеров и сервиса обычно тестируются изолированно. Доменные объ
екты, такие как сущности и объекты значений, чаще всего тестируются с помощью
общительных модульных тестов.
362 Глава 9 • Тестирование микросервисов, часть 1
Рис. 9.11. Назначение класса определяет, какие модульные тесты нужно использовать —
изолированные или общительные
Типичная стратегия тестирования класса выглядит так.
□ Такие сущности, как Order, которые обладают постоянными идентификаторами
(см. главу 5), тестируются с помощью общительных модульных тестов.
□ Такие объекты, как Money, представляющие собой набор значений (см главу 5),
тестируются с применением общительных модульных тестов.
□ Повествования наподобие CreateOrderSaga, которые обеспечивают согласо
ванность данных между сервисами (см. главу 4), тестируются общительными
модульными тестами.
9.2. Написание модульных тестов для сервиса 363
□ Доменные сервисы, такие как OrderService, реализующие бизнес-логику, которая
не подходит для сущностей или объектов значений (см. главу 5), тестируются
с помощью изолированных модульных тестов.
□ Контроллеры, обрабатывающие HTTP-запросы, такие как Ordercontroller, те
стируются с использованием изолированных модульных тестов.
□ Шлюзы для входящих и исходящих сообщений тестируются с помощью изо
лированных модульных тестов.
Для начала посмотрим, как тестируются доменные сущности.
9.2.1. Разработка модульных тестов
для доменных сущностей
В листинге 9.2 показан фрагмент класса OrderTest, реализующий модульные тесты
для сущности Order. Этот класс содержит метод ^Before setUp(), который создает
объект Order перед запуском каждого теста. Методы, помеченные как @Test, могут
выполнить инициализацию объекта Order, вызвать один из его методов и затем
сделать утверждение о его возвращаемом значении и состоянии.
Листинг 9.2. Простой и быстрый модульный тест для сущности Order
public class OrderTest {
private ResultWithEvents<Order> createResult;
private Order order;
^Before
public void setUp() throws Exception {
createResult = Order.createOrder(CONSUMER_ID, A3ANTA_ID, CHICKEN_VINDALOO
_LINE_ITEMS);
order = createResult.result;
}
@Test
public void shouldCalculateTotal() {
assertEquals(CHICKEN_VINDALOO_PRICE.multiply(CHICKEN_VINDALOO_QUANTITY),
order.getOrderTotal());
}
}
Метод @Test shouldCalculateTotal() проверяет, возвращает ли Order.getOr-
derTotal() ожидаемое значение. Модульные тесты тщательно тестируют бизнес-
логику. Они являются общительными и распространяются не только на класс Order,
но и на его зависимости. Вы можете использовать их на этапе компиляции, так как
выполняются они чрезвычайно быстро. Также важно протестировать объект значе
ний Money, от которого зависит класс Order. Посмотрим, как это делается.
364 Глава 9 • Тестирование микросервисов, часть 1
9.2.2. Написание модульных тестов
для объектов значений
Объекты значений не изменяются, поэтому их обычно легко тестировать. Вам не нуж
но беспокоиться о побочных эффектах. Как правило, тест должен создать объект
значений в определенном состоянии, вызвать один из его методов и сделать вывод
о возвращаемом значении. В листинге 9.3 приводятся тесты для объекта Money —
простого класса, который представляет денежное значение. Эти тесты проверяют
поведение методов класса Money, включая add (), который складывает два экземпля
ра Money, и multiply(), который умножает объект Money на целое число. Эти тесты
изолированы, поскольку класс Money не зависит ни от каких других классов при
ложения.
Листинг 9.3. Простой и быстрый тест для объекта значений Money
public class MoneyTest {
private final
private final
private Money
private Money
int M1_AMOUNT = 10;
int M2_AMOUNT = 15;
ml = new Money(M1_AMOUNT);
m2 = new Money(M2_AMOUNT);
@Test
public void shouldAdd() {
Проверяем, можно ли сложить
вместе два объекта Money
assertEquals(new Money(M1_AMOUNT + M2_AMOUNT), ml.add(m2));
}
@Test
public void shouldMultiply() { ◄------
int multiplier = 12;
assertEquals(new Money(M2_AMOUNT *
Проверяем, можно ли умножить
объект Money на целое число
multiplier), m2.multiply(multiplier));
}
Доменные сущности и объекты значений — это кирпичики, из которых состоит
бизнес-логика сервиса. Но иногда она может находиться также в повествованиях
и других сервисах приложения. Посмотрим, как их тестировать.
9.2.3. Разработка модульных тестов для повествований
Повествования, такие как класс CreateOrderSaga, реализуют важную бизнес-логику,
поэтому их тоже нужно тестировать. В данном случае мы имеем дело с сохраняемым
объектом, которые рассылает командные сообщения участникам повествования и об
рабатывает их ответы. Как говорилось в главе 4, класс CreateOrderSaga обменивается
командными/ответными сообщениями с несколькими сервисами, включая Consumer
9.2. Написание модульных тестов для сервиса 365
и Kitchen. Тест для этого класса создает повествование и проверяет, шлет ли тот
ожидаемую последовательность сообщений своим участникам. Один из тестов
должен быть написан для оптимистичного сценария. Но вы должны предусмотреть
и тесты для различных случаев, когда повествование откатывается, получив сообще
ние об отказе от одного из своих участников.
Вы можете написать тесты, которые используют настоящие базу данных и брокер
сообщений, но заменяют участников повествования заглушками. Например, заглуш
ка для сервиса Consumer будет подписываться на командный канал consumerservice
и отправлять обратно сообщение с нужным ответом. Однако тесты, написанные
таким образом, будут довольно медленными. Куда более эффективный подход
заключается в применении макетов место брокера сообщений и классов для взаи
модействия с базой данных. Это позволит вам сосредоточиться на тестировании
основных функций повествования.
В листинге 9.4 показан тест для CreateOrderSaga. Это общительный модульный
тест, который проверяет класс повествования и его зависимости. Он написан с ис
пользованием фреймворка тестирования Eventuate Tram Saga (github.com/eventuate-
tram/eventuate-tram-sagas), который предоставляет простой в применении язык DSL,
абстрагирующий подробности взаимодействия с повествованиями. С помощью этого
языка вы можете создать повествование и убедиться в том, что оно отправляет кор
ректные командные сообщения. При этом фреймворк тестирования подготавливает
макеты для базы данных и инфраструктуры обмена сообщениями.
Листинг 9.4. Простой и быстрый модульный тест для CreateOrderSaga
public class CreateOrderSagaTest {
@Test
public void shouldCreateOrder() {
given()
.saga(new CreateOrderSaga(kitchenServiceProxy),
new CreateOrderSagaState(ORDER_ID,
chicken_vindaloo_order_details)).
expect(). ◄--------
command(new ValidateOrderByConsumer(CONSUMER_IDj ORDER_ID,
Создаем повествование
Проверяем, шлет ли оно
сообщение ValidateOrderByConsumer
сервису Consumer
chicken_vindaloo__order_total) ).
to(ConsumerServiceChannels.consumerServiceChannel).
andGiven().
successReply(). ◄—
Успешно отвечаем
на это сообщение
expect().
command (new CreateTicket (AJANTA_ID, ORDER_ID_, null)). 4—
to(KitchenServiceChannels.kitchenServiceChannel);
Проверяем, шлет ли оно
сообщение CreateTicket
сервису Kitchen}
@Test
public void shouldRejectOrderDueToConsumerVerificationFailed() {
given()
.saga(new CreateOrderSaga(kitchenServiceProxy),
new CreateOrderSagaState(ORDER_IDJ
CHICKEN_VINDALOO__ORDER_DETAILS)).
366 Глава 9 • Тестирование микросервисов, часть 1
expect().
command(new ValidateOrderByConsumer(CONSUMER_ID, ORDER-ID,
CHICKEN_VINDALOO_ORDER_TOTAL)).
to(ConsumerServiceChannels.consumerServiceChannel).
andGiven().
failureReply().
expect().
command(new RejectOrderCommand(ORDER_ID)).
to(OrderServiceChannels.orderServiceChannel); ◄------
Проверяем, шлет ли повествование
сообщение RejectOrderCommand сервису Order
Возвращаем отрицательный ответ:
сервис Consumer отклонил заказ
}
}
Метод @Test shouldCreateOrder() тестирует оптимистичный сценарий. Метод
@Test shouldRejectOrderDueToConsumerVerificationFailed() тестирует случай,
когда сервис Consumer отклоняет заказ. Он проверяет, шлет ли CreateOrderSaga
команду RejectOrderCommand, чтобы компенсировать отклонение действий клиента.
Класс CreateOrderSagaTest содержит методы для тестирования и других отрица
тельных сценариев.
Теперь посмотрим, как тестировать доменные сервисы.
9.2.4. Написание модульных тестов
для доменных сервисов
Большая часть бизнес-логики сервиса реализуется его доменными сущностями,
объектами значений и повествованиями. Остальное содержится в таких классах,
как OrderService. Это типичный класс доменного сервиса. Его методы вызывают
сущности и репозитории и публикуют доменные события. Чтобы его эффективно
протестировать, нужно использовать в основном изолированные модульные тесты,
которые предоставляют макеты для таких зависимостей, как репозитории и классы
обмена сообщениями.
В листинге 9.5 представлен класс OrderServiceTest, который тестирует OrderSer
vice. Он определяет изолированные модульные тесты, заменяющие зависимости
сервиса макетами из состава Mockito. Каждый тест состоит из следующих этапов.
1. Подготовка — конфигурирует объекты-макеты для зависимостей сервиса.
2. Выполнение — вызывает метод сервиса.
3. Проверка — проверяет корректность значения, возвращенного методом сервиса,
и убеждается в том, что зависимости были вызваны правильно.
Метод setUp() создает экземпляр OrderService с внедренными макетами
зависимостей. Метод @Test shouldCreateOrder() проверяет, обратился ли вы
зов OrderService.createOrder() к OrderRepository, чтобы сохранить только что
созданный заказ, опубликовал ли он событие OrderCreatedEvent и создал ли
CreateOrderSaga.
9.2. Написание модульных тестов для сервиса 367
Листинг 9.5. Простой и быстрый модульный тест для класса OrderService
public class OrderServiceTest {
private OrderService orderservice;
private OrderRepository OrderRepository;
private DomainEventPublisher eventpublisher;
private RestaurantRepository restaurantRepository;
private SagaManager<CreateOrderSagaState> createOrderSagaManager;
private SagaManager<CancelOrderSagaData> cancelOrderSagaManager;
private SagaManager<ReviseOrderSagaData> reviseOrderSagaManager;
^Before
public void setup() {
OrderRepository = mock(OrderRepository.class); ◄--------
eventpublisher = mock(DomainEventPublisher.class);
restaurantRepository = mock(RestaurantRepository.class);
createOrderSagaManager = mock(SagaManager.class);
cancelOrderSagaManager = mock(SagaManager.class);
reviseOrderSagaManager = mock(SagaManager.class);
orderservice = new OrderService(orderRepository, eventPublisher, ◄-------------
restaurantRepository, createOrderSagaManager, f
reviseOrderSagaManager); A .экземпляр OrderService
с внедренными макетами зависимостей
Делаем так, чтобы метод RestaurantReposItory.findByldO
вернул ресторан AJanta
Создаем макеты Mockito
для зависимостей
класса OrderService
cancelOrderSagaManager,
}
@Test
public
when(restaurantRepository
void shouldCreateOrder() {
◄—
.findByld(AlANTA.ID)J.thenReturn(Optional.of(A3ANTA_RESTAURANT_);
when(orderRepository.save(any(Order.class))).then(invocation -> { ◄--------------
Order order = (Order) invocation. getArguments () [0]; Делаем так, чтобы
order.setId(ORDER_ID); метод OrderRepository .saveO
^return order; сохранил ID заказа
Вызываем OrderService.createQ
Order order = orderservice.createOrder(CONSUMER_ID,
AJANTA_ID, CHICKEN_VINDALOO_MENU_ITEMS_AND_QUANTITIES);
verify(orderRepository).save(same(order)); +
Проверяем, сохранил ли
класс OrderService
только что созданный заказ в БД
verify(eventPublisher).publish(Order.class, ORDER_ID, ◄-----------------
singletonList(
new OrderCreatedEvent(CHICKEN_VINDALOO_ORDER_DETAILS)));
Проверяем,
опубликовал ли
класс OrderService
verify(createOrderSagaManager) я
.create(new CreateOrderSagaState(ORDER-IDl
CHICKEN-VINDALOO-ORDER-DETAILS),
Order.class, ORDER_ID);
}
событие
OrderCreatedEvent
Проверяем, создал ли
класс OrderCreatedEvent
повествование CreateOrderSaga
368 Глава 9 • Тестирование микросервисов, часть 1
Итак, мы обсудили то, как выполнить модульное тестирование классов бизнес-
логики. Теперь поговорим о том, как сделать то же самое с адаптерами, которые
взаимодействуют с внешними системами.
9.2.5. Разработка модульных тестов
для контроллеров
Сервисы, такие как Order, обычно содержат один или несколько контроллеров для
обработки HTTP-запросов от других сервисов и API-шлюза. Класс контроллера со
стоит из набора методов, обрабатывающих запросы. Каждый такой метод реализует
конечную точку REST API, а его параметры представляют значения НТТР-запроса,
такие как переменные пути. Обычно он обращается к доменному сервису или ре
позиторию и возвращает объект с ответом. Ordercontroller, к примеру, обращается
к OrderService и OrderRepository. Эффективная стратегия тестирования контрол
леров предполагает использование изолированных модульных тестов, которые за
меняют макетами сервисы и репозитории.
Вы могли бы написать класс вроде OrderServiceTest, который создает экземпляр
контроллера и вызывает его методы. Однако такой подход не позволяет проверить
некоторые важные возможности, такие как маршрутизация запросов. Куда более
эффективным будет применение фреймворков для тестирования в стиле MVC,
например Spring Mock Mvc, который входит в состав Spring Framework, или Rest
Assured Mock MVC, основанный на Spring Mock Mvc. Тесты, написанные с по
мощью одной из этих технологий, выполняют нечто похожее на НТТР-запрос
и делают заключение об HTTP-ответах. Эти фреймворки позволяют тестировать
маршрутизацию HTTP-запросов и преобразование объектов Java в формат JSON
и обратно, избегая при этом реальных сетевых вызовов. Spring Mock Mvc автома
тически создает экземпляры ровно того количества классов Spring MVC, которого
должно хватить для тестирования.
В листинге 9.6 показан класс OrderControllerTest, тестирующий Ordercontroller
сервиса Order. Он определяет изолированные модульные тесты, использующие
9.2. Написание модульных тестов для сервиса 369
макеты вместо зависимостей Ordercontroller. Этот класс написан с помощью
фреймворка Rest Assured Mock MVC, который предоставляет простой язык DSL,
абстрагирующий подробности взаимодействия с контроллерами. Rest Assured
упрощает отправку контроллеру поддельных HTTP-запросов и проверку ответов.
OrderControllerTest создает контроллер с внедренными макетами Mockito для
OrderService и OrderRepository. Каждый тест конфигурирует макеты, выполняет
HTTP-запрос, проверяет корректность ответа и, возможно, следит за тем, чтобы
контроллер обратился к этим макетам.
Листинг 9.6. Простой и быстрый модульный тест для класса Ordercontroller
public class OrderControllerTest {
private OrderService orderservice;
private OrderRepository orderRepository;
orderRepository = mock(OrderRepository.class);
ordercontroller = new OrderController(orderService, orderRepository);
^Before
public void setUp() throws Exception {
orderservice = mock(OrderService.class); ◄—
Создаем макеты зависимостей
класса Ordercontroller
}
@Test Делаем так, чтобы макет
public void shouldFindOrder() { OrderRepository возвращал заказ
when(orderRepository.findByld(IL))
.thenReturn(Optional.of(CHICKEN_VINDALOO_ORDER_); ◄----------
given (). I Конфигурируем Ordercontroller
standaloneSetup(configureControllers( ◄----- 1
new OrderController(orderService, orderRepository))).
when 0* | Выполняем НПР-запрос
get('7orders/l"). ◄----------- 1
then().
statusCode(200). <
-► body("orderld",
equalTo(new Long(OrderDetailsMother.ORDER_ID).intValue())).
body("state",
equalTo(OrderDetailsMother.CHICKEN_VINDALOO_ORDER_STATE.name())).
body("orderTotal",
equalTo(CHICKEN_VINDALOO_ORDER_TOTAL.asString()))
Проверяем код состояния ответа
Проверяем
элементы
тела ответа
в формате
JSON
@Test
public void shouldFindNotOrder() { ... }
private StandaloneMockMvcBuilder controllers(Object... controllers) { ... }
370 Глава 9 • Тестирование микросервисов, часть 1
Первым делом тестовый метод shouldFindOrder () конфигурирует макет
OrderRepository так, чтобы тот возвращал Order. Затем он выполняет НТТР-запрос,
чтобы извлечь заказ. В конце проверяет успешность запроса и то, содержит ли тело
ответа ожидаемые данные.
Контроллеры — это не единственный вид адаптеров, обрабатывающих запросы
из внешних систем. Есть также обработчики событий/сообщений. Посмотрим, как
выполняется модульное тестирование для них.
9.2.6. Написание модульных тестов для обработчиков
событий и сообщений
Сервисы часто обрабатывают сообщения, переданные внешними системами. К при
меру, сервис Order содержит адаптер сообщений Order Eventconsumer, обрабатыва
ющий доменные события, публикуемые другими сервисами. Как и контроллеры,
адаптеры сообщений обычно представляют собой простые классы, которые обраща
ются к доменным сервисам. Каждый метод адаптера, как правило, вызывает метод
сервиса, передавая ему данные из сообщения или события.
Для модульного тестирования адаптеров сообщений можно применить подход,
аналогичный используемому для контроллеров. Каждый тест создает экземпляр
адаптера, отправляет сообщение в канал и проверяет корректность обращения к ма
кету сервиса. Одновременно автоматически создаются заглушки для инфраструкту
ры обмена сообщениями, поэтому брокер не участвует в этом процессе. Посмотрим,
как протестировать класс OrderEventConsumer.
В листинге 9.7 показан фрагмент класса OrderEventConsumerTest, который те
стирует адаптер OrderEventConsumer. Он проверяет, направляет ли тот каждое со
бытие подходящему методу-обработчику, и следит за корректностью обращения
к OrderService. Здесь применяется фреймворк Eventuate Tram Mock Messaging,
который предоставляет простой в использовании язык DSL для создания макетов
инфраструктуры обмена сообщениями. Он имеет тот же формат тестов «дано — ко
гда — тогда», что и Rest Assured. Каждый тест создает экземпляр OrderEventConsumer
с внедренным макетом OrderService, публикует доменное событие и проверяет,
корректно ли OrderEventConsumer обращается к макету сервиса.
Метод setUp() создает экземпляр OrderEventConsumer с внедренным макетом
OrderService. Метод shouldCreateMenu() публикует событие RestaurantCreated
и проверяет, вызвал ли объект OrderEventConsumer метод OrderService. createMenu().
OrderEventConsumerTest, как и другие классы для модульного тестирования, выпол
няется очень быстро. Модульные тесты работают всего несколько секунд.
Однако эти тесты не проверяют, насколько корректно сервис наподобие Order
взаимодействует с другими сервисами. Например, они не позволяют убедиться в том,
что заказ можно сохранить в MySQL или что CreateOrderSaga шлет командные со
общения в правильном формате и в нужный канал. И с их помощью нельзя узнать,
имеет ли событие RestaurantCreated, обработанное адаптером OrderEventConsumer,
ту же структуру, что и события, публикуемые сервисом Restaurant. Для тестирова
ния корректности взаимодействия между сервисами необходимо писать интеграци-
Резюме 371
онные тесты. Также понадобятся компонентные тесты, которые тестируют отдельно
взятый сервис целиком. Все это, а также выполнение сквозного тестирования, мы
обсудим в следующей главе.
Листинг 9.7. Быстрый модульный тест для класса OrderEventConsumer
public class OrderEventConsumerTest {
private OrderService orderservice;
private OrderEventConsumer OrderEventConsumer;
^Before
public void setUp() throws Exception {
orderservice = mock(OrderService.class);
OrderEventConsumer = new OrderEventConsumer(orderService); <
@Test
public void shouldCreateMenu() {
Создаем экземпляр OrderEventConsumer
с поддельными зависимостями
Конфигурируем доменные
обработчики OrderEventConsumer
given().
eventHandlers(OrderEventConsumer.domainEventHandlers()). <---------
when().
aggregate("net.chrisrichardson.ftgo.restaurantservice.domain.Restaurant",
A3ANTA_ID).
publishes(new RestaurantCreated(AJANTA_RESTAURANT_NAME, ◄--------- _ „
RestaurantMother.AJANTA_RESTAURANT_MENU)) Публикуем событие
RestaurantCreated
Проверяем, вызвал ли экземпляр
OrderEventConsumer метод OrderService.createMenuO
then().
verify(() -> { ◄---------------
verify(orderservice)
.createMenu(A3ANTA_ID,
new RestaurantMenu(RestaurantMother.AJANTA_RESTAURANT_MENU_ITEMS));
})
}
Резюме
□ Автоматическое тестирование лежит в основе быстрой и безопасной доставки
программного обеспечения. К тому же микросервисная архитектура сложна по
своей природе, поэтому, чтобы воспользоваться всеми ее преимуществами, вы
должны автоматизировать свои тесты.
□ Тест нужен для того, чтобы проверить поведение тестируемой системы. В дан
ном случае под системой понимается тестируемый элемент программного обе
спечения. Это может быть как отдельный класс, так и приложение целиком.
Или же что-то среднее, например коллекция классов или отдельный сервис.
Связанные между собой тесты объединяются в тестовый набор.
372 Глава 9 • Тестирование микросервисов, часть 1
□ Хороший способ упрощения и ускорения тестов — задействование дублеров.
Дублер — это объект, который симулирует поведение зависимости тестируемой
системы. Существует два типа дублеров: заглушки и макеты. Заглушка возвра
щает значение тестируемой системе. Макет используется тестом для проверки,
корректно ли система вызывает свои зависимости.
□ Применяйте пирамиду тестов, чтобы определить, где следует приложить усилия
при тестировании сервисов. Большинство ваших тестов должны быть модульны
ми, то есть быстрыми, надежными и простыми в написании. Вы должны миними
зировать количество сквозных тестов, поскольку они медленные и нестабильные,
а их написание занимает много времени.
Тестирование
микросервисов, часть 2
Здесь мы будем отталкиваться от предыдущей главы, в которой были представлены
концепции тестирования, включая пирамиду тестов. Пирамида тестов описывает
относительные пропорции разных типов тестирования, которые вы должны при
менять. Из предыдущей главы вы узнали, как писать модульные тесты, лежащие
в основе этой пирамиды. Продолжим восхождение к ее вершине.
Эта глава начинается с того, как писать интеграционные тесты, которые находят
ся на уровень выше модульных. Интеграционные тесты проверяют корректность
взаимодействия сервиса с инфраструктурой, включая базы данных и другие сервисы
приложения. Вслед за этим мы обсудим компонентные тесты, которые являются
формой приемочного тестирования сервисов. Компонентный тест проверяет рабо
ту сервиса в изоляции, используя заглушки вместо его зависимостей. Дальше вы
узнаете, как писать сквозные тесты, которые тестируют группу сервисов или целое
приложение. Сквозные тесты находятся на вершине пирамиды, поэтому применять
их следует как можно реже.
Начнем с рассмотрения интеграционных тестов.
374 Глава 10 • Тестирование микросервисов, часть 2
10.1. Написание интеграционных тестов
Обычно сервисы взаимодействуют друг с другом. Например, сервис Order общается
с несколькими другими сервисами (рис. 10.1). Его REST API потребляется API-
шлюзом, а доменные события обрабатываются такими сервисами, как Order History.
Последний, в свою очередь, использует еще несколько компонентов приложения:
сохраняет заказы в MySQL и обменивается командами/ответами с несколькими
другими сервисами, такими как Kitchen.
Рис. 10.1. Интеграционные тесты должны проверить, может ли сервис взаимодействовать
со своими клиентами и зависимостями. Но вместо тестирования целых сервисов проверяются
классы отдельных адаптеров, которые реализуют взаимодействие
Чтобы удостовериться в том, что сервис Order ведет себя как положено, мы долж
ны написать тесты, проверяющие его способность корректно взаимодействовать
с инфраструктурой и другими сервисами приложения. Для этого можно запустить
все сервисы и протестировать их API. Подобного рода тесты называют сквозными,
они работают медленно, склонны к ошибкам и требуют больших затрат. Как вы
увидите в разделе 10.3, такой вид тестирования тоже имеет право на жизнь, но он
находится на самой вершине пирамиды тестов, поэтому его использование следует
минимизировать.
10.1. Написание интеграционных тестов 375
Гораздо более эффективная стратегия — написание так называемых интегра
ционных тестов. В пирамиде тестирования интеграционные тесты находятся на
уровень выше модульных (рис. 10.2). Они помогают убедиться в том, что сервис как
следует взаимодействует с инфраструктурой и другими сервисами. Но, в отличие от
сквозных, они эти сервисы не запускают. Вместо этого задействуются две стратегии,
которые существенно упрощают код тестов, не влияя на их эффективность.
Рис. 10.2. Интеграционные тесты находятся на уровень выше модульных. Они проверяют,
может ли сервис взаимодействовать со своими зависимостями, включая инфраструктурные
компоненты наподобие базы данных и другие сервисы приложения
Первая стратегия заключается в тестировании каждого адаптера сервиса, воз
можно, вместе со вспомогательными классами. Например, в подразделе 10.1.1
будет представлен тест, который проверяет корректность сохранения заказов с по
мощью J РА. Вместо использования API сервиса Order он напрямую тестирует класс
OrderRepository. А в подразделе 10.1.3 вы увидите тест, который, работая с классом
OrderDomainEventPublisher, проверяет корректность структуры событий, публику
емых сервисом Order. Если сосредоточиться на небольшом количестве классов, а не
на сервисе в целом, ваши тесты получатся куда более простыми и быстрыми.
Вторая стратегия упрощения интеграционных тестов, которые проверяют меж
сервисное взаимодействие, состоит в использовании контрактов (см. главу 9).
Контракт — это конкретный пример взаимодействия между двумя сервисами.
Как показано в табл. 10.1, структура контракта зависит от того, как именно сервисы
общаются между собой.
Таблица 10.1. Структура контракта зависит от типа взаимодействия между сервисами
Стиль взаимодействия Потребитель Провайдер Контракт
Запросы/ответы на основе REST API-шлюз Сервис Order HTTP-запрос и ответ
Издатель/подписчик Сервис Order History Сервис Order Доменное событие
Асинхронные запросы/ответы Сервис Order Сервис Kitchen Командное и ответное
сообщения
376 Глава 10 • Тестирование микросервисов, часть 2
Контракт состоит из одного или двух сообщений. Первый случай подходит для
взаимодействия в стиле «издатель/подписчик», а второй — для асинхронных за
просов/ответов.
Контракты используются для тестирования как потребителя, так и провайдера.
Это позволяет убедиться в том, что их API согласованы. Структура контракта ва
рьируется в зависимости от того, какую сторону мы тестируем.
□ Тесты на стороне потребителя — тесты для потребительского адаптера. Контракты
в них применяются для конфигурации заглушек, симулирующих работу про
вайдера. Благодаря этому вам не нужен запущенный провайдер, чтобы проте
стировать потребителя.
□ Тесты на стороне провайдера — проверяют адаптер провайдера. Они используют
контракты для тестирования адаптеров, заменяя их зависимости макетами.
Позже в этом разделе я покажу примеры такого рода тестов, но сначала рассмо
трим тестирование с сохранением.
10.1.1. Интеграционные тесты с сохранением
Обычно сервисы хранят информацию в базе данных. Например, OrderService хранит
агрегаты, такие как Order, в MySQL, используя при этом JPA. Точно так же CQRS-
представление сервиса Order History размещается в AWS DynamoDB. Модульные
тесты, которые мы написали ранее, проверяют только те объекты, которые находятся
в памяти. Чтобы убедиться в корректной работе сервиса, мы должны написать инте
грационные тесты с сохранением, которые проверяют, работает ли логика доступа
к базе данных так, как мы того ожидаем. В случае с сервисом Order это означает
тестирование JPA-репозиториев, таких как OrderRepository.
Этапы интеграционного теста с сохранением ведут себя следующим образом.
□ Подготовка — подготавливает базу данных, создавая ее схему и приводя ее к из
вестному состоянию. Этот этап также может инициировать транзакцию.
□ Выполнение — выполняет операцию с базой данных.
□ Проверка — делает вывод о состоянии базы данных и извлеченных из нее объ
ектов.
□ Очистка — опциональный этап, который может отменить изменения, внесенные
в базу данных, например, путем отката транзакции, инициированной на этапе
подготовки.
В листинге 10.1 показан интеграционный тест с сохранением для агрегата Order
и OrderRepository. Помимо использования JPA для создания схемы базы данных,
подобные тесты не делают никаких предположений о состоянии БД. Таким образом,
им не нужно откатывать изменения, внесенные в базу данных. Это позволяет избе
жать проблем, связанных с тем, что ORM кэширует измененные данные в память.
10.1. Написание интеграционных тестов 377
Листинг 10.1. Интеграционный тест, который проверяет возможность сохранения заказа
gRunWith(SpringRunner.class)
gSpringBootTest(classes = OrderlpaTestConfiguration.class)
public class OrderlpaTest {
gAutowired
private OrderRepository OrderRepository;
gAutowired
private TransactionTemplate transactionTemplate;
gTest
public void shouldSaveAndLoadOrder() {
Long orderld = transactionTemplate.execute((ts) -> {
Order order =
new Order(CONSUMER_ID, AJANTA_ID, CHICKEN_VINDALOO_LINE—ITEMS);
OrderRepository.save(order);
return order.getld();
});
transactionTemplate.execute((ts) -> {
Order order = OrderRepository.findByld(orderld).get();
assertEquals(Orderstate.APPROVAL-PENDING, order.getState());
assertEquals(AJANTA_ID, order.getRestaurantId());
assertEquals(CONSUMER-ID, order.getConsumerld().longValue());
assertEquals(CHICKEN_VINDALOO_LINE_ITEMS, order.getLineItems());
return null;
});
}
}
Тестовый метод shouldSaveAndLoadOrder() выполняет две транзакции. Первая
сохраняет только что созданный заказ в базе данных. Вторая загружает этот заказ
и проверяет, правильно ли инициализированы его поля.
Но как обеспечить базу данных, которая используется в интеграционных тестах
с сохранением? Эффективное решение для запуска экземпляра БД во время те
стирования — применение Docker. В разделе 10.2 описывается, как автоматически
запускать сервисы во время компонентного тестирования с помощью дополнения
Docker Compose Gradle. В интеграционных тестах с помощью аналогичного подхода
можно запускать, скажем, MySQL.
База данных — это всего лишь один из внешних компонентов, с которым взаимо
действует сервис. Посмотрим, как писать интеграционные тесты для взаимодействия
между сервисами приложения. Начнем с REST.
378 Глава 10 • Тестирование микросервисов, часть 2
10.1.2. Интеграционное тестирование взаимодействия
в стиле «запрос/ответ» на основе REST
REST широко используется в качестве механизма межсервисного взаимодействия.
Клиент и сервис должны согласовать интерфейс REST API — это относится как
к конечным точкам, так и к структуре тела запроса и ответа. Клиент должен послать
НТТР-запрос подходящей конечной точке, а сервис — вернуть ответ, которого кли
ент от него ждет.
Например, в главе 8 описывалось, как API-шлюз приложения FTGO исполь
зует REST API для вызова многочисленных сервисов, включая Consumerservice,
OrderService и Deliveryservice. Конечная точка GET /orders/forderld} сервиса
Order является одной из тех, к которым обращается API-шлюз. Чтобы без сквозного
тестирования убедиться в том, что API-шлюз и сервис Order могут взаимодейство
вать, нужно написать интеграционные тесты.
Как утверждалось в главе 9, хорошая стратегия интеграционного тестирования
состоит в применении контрактов с расчетом на потребителя. Взаимодействие между
API-шлюзом и конечной точкой GET /orders/{orderld} можно описать с помощью
набора контрактов, основанных на HTTP. Каждый контракт состоит из НТТР-
запроса и HTTP-ответа. Эти контракты используются для тестирования API-шлюза
и сервиса Order.
На рис. 10.3 показано, как тестируются взаимодействия на основе REST с приме
нением Spring Cloud Contract. Интеграционные тесты потребительской стороны для
API-шлюза задействуют контракты для конфигурации фиктивного НТТР-сервера,
симулирующего поведение сервиса Order. Запрос контракта описывает НТТР-
запрос, выполняемый API-шлюзом, а его ответ определяет результат, который за
глушка шлет обратно. Spring Cloud Contract использует контракты для генерации
кода интеграционных тестов на стороне провайдера, тестирующих контроллеры
сервиса Order с помощью Spring Mock MVC или Rest Assured Mock MVC. Запрос
контракта описывает НТТР-запрос, направляемый контроллеру, а его ответ опре
деляет результат, который контроллер должен вернуть.
Тест потребительской стороны OrderServiceProxyTest вызывает объект OrderSer-
viceProxy, сконфигурированный для отправки HTTP-запросов к WireMock.
WireMock — это инструмент для эффективной симуляции HTTP-серверов, в данном
тесте он симулирует сервис Order. Spring Cloud Contract настраивает экземпляр
WireMock так, чтобы тот отвечал на HTTP-запросы, определенные контрактами.
На стороне провайдера Spring Cloud Contract генерирует тестовый класс под
названием HttpTest, который тестирует контроллеры сервиса Order с помощью
Rest Assured Mock MVC. Тестовые классы наподобие HttpTest должны наследовать
базовый класс, написанный вручную. В данном примере базовый класс BaseHttp
создает экземпляр Ordercontroller с внедренными макетами зависимостей и вы
зывает метод RestAssuredMockMvc. standaloneSetup(), чтобы сконфигурировать
Spring MVC.
Давайте разберемся, как все это работает, на примере контракта.
10.1. Написание интеграционных тестов 379
Рис. 10.3. Тест проверяет, соответствуют ли контракту классы адаптеров на обеих сторонах
взаимодействия на основе REST между API-шлюзом и сервисом Order. Тесты на стороне потребителя
проверяют корректность обращения OrderServiceProxy к сервису Order. Тесты на стороне провайдера
проверяют корректность реализации конечных точек REST API контроллером Ordercontroller
Пример контракта для REST API
REST-контракт, пример которого приведен в листинге 10.2, описывает НТТР-
запрос, отправляемый REST-клиентом, и HTTP-ответ, который клиент ожидает
получить от REST-сервера. Запрос контракта определяет HTTP-метод, путь
и опциональные заголовки. В ответе контракта указываются HTTP-код состояния,
опциональные заголовки и, если нужно, ожидаемое тело ответа.
Листинг 10.2. Контракт, описывающий взаимодействие в стиле «запрос/ответ» на основе HTTP
org.springframework.cloud.contract.spec.Contract.make {
request {
method 'GET’
url '/orders/1223232’
}
response {
status 200
headers {
header('Content-Type': 'application/json;charset=UTF-8')
}
body(' "{"orderld" : "1223232", "state" : "APPROVAL-PENDING"}' " )
}
}
Этот конкретный контракт описывает успешную попытку извлечения API-
шлюзом заказа из сервиса Order. Теперь посмотрим, как с его помощью написать
интеграционные тесты для OrderService.
380 Глава 10 • Тестирование микросервисов, часть 2
Интеграционные тесты контракта с расчетом
на потребителя для сервиса Order
Интеграционные тесты контракта с расчетом на потребителя для сервиса Order прове
ряют, отвечает ли его API ожиданиям клиента. В листинге 10.3 показан базовый класс
HttpBase, из которого Spring Cloud Contract генерирует тестовый класс. Он создает
контроллеры с внедренными макетами зависимостей и делает так, чтобы возвраща
емые ими значения заставляли контроллер сгенерировать ожидаемый ответ.
Листинг 10.3. Абстрактный базовый класс для тестов, которые генерирует фреймворк Spring
Cloud Contract
public abstract class HttpBase {
private StandaloneMockMvcBuilder controllers(Object... controllers) {
return MockMvcBuilders.standaloneSetup(controllers)
.setMessageConverters(...);
}
^Before
public void setup() {
OrderService orderservice = mock(OrderService.class);
Создаем OrderRepository
с внедренными макетами
OrderRepository orderRepository = mock(OrderRepository.class);
Ordercontroller ordercontroller =
new OrderController(orderService, orderRepository);
when(orderRepository.findById(1223232L)) ◄-------------
.thenReturn(Optional.of(OrderDetailsMother.CHICKEN_VINDALOO_ORDER));
RestAssuredMockMvc.standaloneSetup(controllers(orderController)); ◄-------1
Конфигурируем Ordercontroller с помощью Spring MVCI
Делаем так, чтобы при вызове findByldO объект OrderResponse
возвращал заказ с полем orderld, указанным в контракте
Аргумент 1223232L, который передается методу f indById() макета OrderRepository,
совпадает со значением orderld, указанным в контракте из листинга 10.3. Этот тест
проверяет наличие у сервиса Order конечной точки GET /orders/{orderld}, которая
отвечает ожиданиям клиента.
Рассмотрим соответствующий клиентский тест.
Интеграционный тест на стороне потребителя
для класса API-шлюза OrderServiceProxy
Класс API-шлюза OrderServiceProxy обращается к конечной точке GET /orders/
{orderld}. В листинге 10.4 показан тестовый класс OrderServiceProxylntegra-
tionTest, который проверяет, соответствует ли этот прокси контракту. Этот класс
помечен аннотацией @AutoConf igureStubRunner из состава Spring Cloud Contract.
Благодаря этому фреймворк Spring Cloud Contract запускает сервер WireMock на
случайном порте и конфигурирует его с помощью заданных контрактов. OrderSer-
10.1. Написание интеграционных тестов 381
viceProxylntegrationTest делает так, чтобы прокси OrderServiceProxy отправлял
свои запросы на порт WireMock.
Листинг 10.4. Интеграционный тест на стороне потребителя для класса API-шлюза
OrderServiceProxy
Делаем так, чтобы
Spring Cloud Contract
сконфигурировал
WireMock с помощью
контрактов сервиса Order
gRunWith(SpringRunner.class)
@SpringBootTest(classes=TestConfiguration.class,
webEnvironment= SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(ids = ◄-----------------
{"net.chrisrichardson.ftgo.contracts:ftgo-order-service-contracts"},
workOffline = false)
gDirtiesContext
public class OrderServiceProxylntegrationTest {
@Value("${stubrunner.runningstubs.ftgo-order-service-contracts.port}")
private int port;
private OrderDestinations orderDestinations;
private OrderServiceProxy orderservice;
^Before
public void setup() throws Exception {
orderDestinations = new OrderDestinations(); ◄-------------
String orderServiceUrl = "http://localhost:" + port;
orderDestinations.setOrderServiceUrl(orderServiceUrl);
orderservice = new OrderServiceProxy(orderDestinations,
Получаем случайно
назначенный порт,
на котором работает WireMock
WebClient.create());
Создаем прокси OrderServiceProxy,
настроенный для выполнения
запросов к WireMock
@Test
public void shouldVerifyExistingCustomer() {
Orderinfo result = orderService.findOrderById("1223232").block();
assertEquals("1223232", result.getOrderld());
assertEquals("APPROVAL-PENDING", result.getState());
}
@Test(expected = OrderNotFoundException.class)
public void shouldFailToFindMissingOrder() {
orderservice.findOrderByld("555").block();
}
}
Каждый тестовый метод обращается к прокси OrderServiceProxy и проверяет,
возвращает ли тот корректные значения или генерирует ожидаемое исключение.
Тестовый метод shouldVerifyExistingCustomer() проверяет, совпадает ли возвра
щаемое значение вызова f indOrderById() с тем, которое указано в ответе контракта.
Метод shouldFailToFindMissingOrder() пытается извлечь несуществующий заказ
и проверяет, генерирует ли OrderServiceProxy исключение OrderNotFoundException.
Тестирование REST-клиента и REST-сервера с помощью одного контракта обеспе
чивает согласованность их API.
Теперь поговорим о том, как выполнить аналогичное тестирование сервисов,
взаимодействующих путем обмена сообщениями.
382 Глава 10 • Тестирование микросервисов, часть 2
10.1.3. Интеграционное тестирование взаимодействия
в стиле «издатель/подписчик»
Сервисы часто публикуют доменные события, потребляемые другими сервисами
(одним или несколькими). При интеграционном тестировании нужно убедиться
в том, что издатель и его подписчики согласовали канал сообщений и структуру
доменных событий. Например, сервис Order публикует события типа Order* при
каждом создании или обновлении агрегата Order. Один из потребителей этих со
бытий — сервис Order History. Таким образом, мы должны написать тесты, которые
проверяют возможность взаимодействия между этими сервисами.
То, как производится интеграционное тестирование взаимодействия в стиле «из
датель/подписчик», показано на рис. 10.4. Процесс похож на тестирование общения
по REST. Как и прежде, взаимодействие описывается в виде набора контрактов.
Отличие лишь в том, что в каждом контракте задается доменное событие.
Рис. 10.4. Контракты используются для тестирования обеих сторон взаимодействия в стиле
«издатель/подписчик». Тесты на стороне провайдера проверяют, соответствуют ли контракту
события, публикуемые издателем OrderDomainEventPublisher. Тесты на стороне потребителя
проверяют, потребляет ли OrderHistoryEventHandlers демонстрационные события из контракта
10.1. Написание интеграционных тестов 383
Каждый тест на стороне потребителя публикует событие, заданное контрактом,
и проверяет, корректно ли OrderHistoryEventHandlers вызывает свои фиктивные
зависимости.
На стороне провайдера Spring Cloud Contract генерирует тестовые классы, на
следованные от абстрактного родительского класса MessagingBase, написанного
вручную. Каждый тестовый метод вызывает метод-перехватчик, определенный
в MessagingBase, который должен инициировать публикацию события со сторо
ны сервиса. В этом примере каждый метод-перехватчик обращается к издателю
OrderDomainEventPublisher, ответственному за публикацию событий агрегата Order.
Затем тестовый метод проверяет, было ли опубликовано ожидаемое событие. Давай
те подробно рассмотрим, как работают эти тесты. Начнем с контракта.
Контракт для публикации события OrderCreated
В листинге 10.5 показан контракт для события OrderCreated. Он определяет канал
события, а также ожидаемые тело и заголовки сообщения.
Листинг 10.5. Контракт для взаимодействия в стиле «издатель/подписчик»
package contracts;
org.springframework.cloud.contract.spec.Contract.make {
label 'orderCreatedEvent'
input {
triggeredBy('OrderCreated()') ◄-
}
Используется потребителем,
чтобы инициировать публикацию события
> outputMessage {
Доменное
событие
OrderCreated
Вызывается сгенерированным
тестом провайдера
sentTo('net.chrisrichardson.ftgo.orderservice.domain.Order')
body('',{"orderDetails":{"lineItems":[{"quantity":5,"menultemld":"1",
"name":"Chicken Vindaloo"/'price":"12.34"/'total":"61.70"}],
"orderTotal":"61.70","restaurantld":1,
"consumerld":1511300065921},"orderstate":"APPROVAL_PENDING"}'’')
headers {
header('event-aggregate-type',
'net.chrisrichardson.ftgo.orderservice.domain.Order')
header('event-aggregate-id', ’1')
}
}
}
Контракт имеет еще два важных элемента:
□ label — используется потребителем, чтобы инициировать публикацию события
фреймворком Spring Contact;
□ triggeredBy — имя метода родительского класса, который вызывается сгенери
рованным тестовым методом, чтобы инициировать публикацию события.
Посмотрим, как применять этот контракт. Начнем с теста па стороне провайдера
для сервиса Order.
384 Глава 10 • Тестирование микросервисов, часть 2
Тесты контрактов с расчетом на потребителя
для сервиса Order
Тест на стороне провайдера для сервиса Order — это еще один интеграционный тест
контракта с расчетом на потребителя. Он позволяет убедиться в том, что издатель
OrderDomainEventPublisher, который отвечает за публикацию доменных событий
агрегата Order, публикует события, соответствующие ожиданиям его клиента. В ли
стинге 10.6 показан базовый класс MessagingBase, на котором основаны тестовые
классы, генерируемые фреймворком Spring Cloud Contract. Он конфигурирует класс
OrderDomainEventPublisher так, чтобы тот использовал фиктивную инфраструктуру
обмена сообщениями, находящуюся в памяти. Он также определяет методы, такие
как orderCreated(), с помощью которых сгенерированные тесты инициируют пу
бликацию события.
Листинг 10.6. Абстрактный базовый класс для тестов на стороне провайдера, генерируемых
фреймворком Spring Cloud Contract
@RunHith(SpringRunner.class)
@SpringBootTest(classes = MessagingBase.TestConfiguration.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureMessageVerifier
public abstract class MessagingBase {
^Configuration
@EnableAutoConfiguration
@Import({EventuateContractVerifierConfiguration.class,
TramEventsPublisherConfiguration.class,
TramlnMemoryConfiguration.class})
public static class Testconfiguration {
@Bean
public OrderDomainEventPublisher
OrderDomainEventPublisher(DomainEventPublisher eventPublisher) {
return new OrderDomainEventPublisher(eventPublisher);
} orderCreatedO вызывается
} сгенерированным тестовым
подклассом для публикации события
@Autowired
private OrderDomainEventPublisher OrderDomainEventPublisher;
protected void orderCreatedO { ◄--------------------------------------------
OrderDomainEventPublisher.publish(CHICKEN_VINDALOO_ORDER,
singletonList(new OrderCreatedEvent(CHICKEN_VINDALOO_ORDER_DETAILS)
));
}
}
Этот тестовый класс настраивает OrderDomainEventPublisher для использования
фиктивной инфраструктуры обмена сообщениями. orderCreatedO вызывается
10.1. Написание интеграционных тестов 385
тестовым методом, сгенерированным из контракта, показанного в листинге 10.5.
Он вызывает OrderDomainEventPublisher, чтобы опубликовать событие OrderCreated.
Тестовый метод пытается получить это событие и затем проверяет, совпадает ли оно
с тем, что было указано в контракте. Теперь рассмотрим соответствующие тесты на
стороне потребителя.
Тест контракта на стороне потребителя
для сервиса Order History
Сервис Order History потребляет события, публикуемые сервисом Order. Как опи
сывалось в главе 7, для обработки этих событий используется адаптер класса
OrderHistoryEventHandlers. Его обработчики обращаются к объекту OrderHistoryDao,
чтобы обновить CQRS-представление. В листинге 10.7 показан интеграционный
тест на стороне потребителя. Он создает экземпляр OrderHistoryEventHandlers
с внедренным макетом OrderHistoryDao. Каждый тестовый метод сначала применяет
Spring Cloud, чтобы опубликовать событие, указанное в контракте, а затем проверяет
корректность обращения OrderHistoryEventHandlers к OrderHistoryDao.
Листинг 10.7. Интеграционный тест на стороне потребителя
для класса OrderHistoryEventHandlers
@RunWith(SpringRunner.class)
@SpringBootTest(classes= OrderHistoryEventHandlersTest.TestConfiguration.class,
webEnvironment= SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(ids =
{"net.chrisrichardson.ftgo.contracts:ftgo-order-service-contracts"},
workOffline = false)
gDirtiesContext
public class OrderHistoryEventHandlersTest {
@Configuration
@EnableAutoConfiguration
@Import({OrderHistoryServiceMessagingConfiguration.class,
TramCommandProducerConfiguration.class,
TramlnMemoryConfiguration.class,
EventuateContractVerifierConfiguration.class})
public static class Testconfiguration {
@Bean
public OrderHistoryDao orderHistoryDao() {
return mock(OrderHistoryDao.class); ◄—
}
Создаем макет OrderHistoryDao, чтобы
внедрить его в OrderHistoryEventHandlers
} Инициирует фиктивный метод OrderCreatedEvent,
который генерирует событие OrderCreated
public void shouldHandleOrderCreatedEvent() throws ... {
stubFinder.trigger("OrderCreatedEvent"); ◄------------------------------------------------
386 Глава 10 • Тестирование микросервисов, часть 2
eventually(() -> { ◄----------------
verify(orderHistoryDao).addOrder(any(Order.class), any(Optional.class));
у ' Проверяем, вызывает ли OrderHistoryEventHandlers
метод orderHistoryDao.addOrderO
Тестовый метод shouldHandleOrderCreatedEvent() заставляет Spring Cloud
Contract опубликовать событие OrderCreated. Затем он проверяет, вызвал ли класс
OrderHistoryEventHandlers метод orderHistoryDao.addOrderO. Тестирование из
дателя и потребителя доменных событий с помощью одного контракта обеспечи
вает согласованность их API. Теперь посмотрим, как выполнить интеграционное
тестирование сервисов, которые взаимодействуют с использованием асинхронных
запросов / ответов.
10.1.4. Интеграционные тесты контрактов
для взаимодействия на основе
асинхронных запросов/ответов
«Подписчик/издатель» — это не единственный стиль взаимодействия на основе
сообщений. Сервисы могут общаться также с помощью асинхронных запросов/от
ветов. Например, в главе 4 мы видели, как сервис Order реализует повествования,
которые рассылают командные сообщения различным сервисам, таким как Kitchen,
и обрабатывает полученные ответы.
Во взаимодействии подобного рода одна из сторон является запрашивающей
(сервис, отправляющий команды), а другая — отвечающей (сервис, который обра
батывает команду и возвращает ответ). Они должны согласовать название канала
для командных сообщений и структуру запросов/ответов. Попробуем написать
интеграционные тесты для такого вида взаимодействия.
На рис. 10.5 показано, как протестировать взаимодействие между сервисами
Order и Kitchen. Подход к интеграционному тестированию взаимодействия на
основе асинхронных запросов/ответов довольно близок к использованному при
тестировании общения по REST. Коммуникация между сервисами определяется
набором контрактов. Отличие состоит в том, что вместо HTTP-запросов и ответов
контракт описывает входящее и исходящее сообщения.
Тест на стороне потребителя позволяет убедиться в том, что прокси-класс коррект
но структурирует командные сообщения и корректно обрабатывает ответы. В этом
примере класс KitchenServiceProxyTest тестирует KitchenServiceProxy. С помощью
Spring Cloud Contract он конфигурирует заглушки, которые проверяют, совпадает ли
команда с входящим сообщением контракта, а после этого выдает соответствующее
исходящее сообщение.
Тесты на стороне провайдера генерируются фреймворком Spring Cloud Contract.
Каждый тестовый метод относится к какому-то контракту. Он отправляет входящее
10.1. Написание интеграционных тестов 387
сообщение контракта в качестве команды и проверяет, совпадает ли исходящее со
общение того же контракта с полученным ответом. Давайте подробнее рассмотрим
этот процесс, начиная с контракта.
Рис. 10.5. Контракты применяются для тестирования классов адаптеров, реализующих обе стороны
асинхронного взаимодействия вида «запрос/ответ». Тесты на стороне провайдера проверяют,
обрабатывает ли класс KitchenServiceCommandHandler команды и возвращает ли ответы. Тесты
на стороне потребителя проверяют, отправляет ли класс KitchenServiceProxy команды, которые
соответствуют контракту, и обрабатывает ли демонстрационные ответы, возвращаемые контрактом
Пример контракта для асинхронных запросов/ответов
В листинге 10.8 показан контракт для одного взаимодействия. Он состоит из вхо
дящего и исходящего сообщений. У обоих сообщений есть канал, тело и заголовок.
Соглашение об именовании показано с точки зрения провайдера. Элемент messageFrom
входящего сообщения указывает канал, из которого происходит чтение. Точно так
же элемент sentTo исходящего сообщения определяет канал, в который будут от
правляться ответы.
388 Глава 10 • Тестирование микросервисов, часть 2
Листинг 10.8. Контракт, описывающий, как сервис Order асинхронно обращается к сервису Kitchen
package contracts;
Командное сообщение,
отправленное сервисом Order
в канал kitchenService<
org.springframework.cloud.contract.spec.Contract.make {
label 'createTicket'
input {
messageFrom('kitchenService')
messageBody(” ' {"orderld":1,"restaurantld":1,"ticketDetails":{...}}’'')
messageHeaders {
header(’command-type','net.chrisrichardson...CreateTicket')
header('command—sag3—type'j'net.chrisrichardson...CreateOrderSaga')
header('command_saga_id',$(consumer(regex('[0-9a-f]{16}-[0-9a-f]
{16}’))))
header('command_reply_to','net.chrisrichardson...CreateOrderSaga-Reply')
Ответное сообщение,
отправленное сервисом Kitchen
}
}
outputMessage {
sentTo('net.chrisrichardson...CreateOrderSaga-reply')
body([
ticketld: 1
])
headers {
header('reply_type', 'net.chrisrichardson...CreateTicketReply')
header('reply_outcome-type', 'SUCCESS')
}
}
}
В этом примере контракта роль входящего сообщения играет команда CreateTicket,
которая отправляется в канал kitchenService. Исходящее сообщение представ
лено успешным ответом, который отправляется в канал ответов повествования
CreateOrderSaga. Посмотрим, как эти контракты используются в тестах. Начнем
с тестов на стороне потребителя для сервиса Order.
Интеграционные тесты контрактов на стороне потребителя
для взаимодействия в виде асинхронных запросов/ответов
Написание интеграционных тестов на стороне потребителя для асинхронного взаи
модействия в стиле «запрос/ответ» похоже на тестирование REST-клиента. Тест об
ращается к прокси-классу сервиса, отвечающему за обмен сообщениями, и проверяет
два аспекта его поведения: соответствует ли контракту командное сообщение, от
правленное прокси-классом, и корректно ли последний обрабатывает ответ.
В листинге 10.9 показан интеграционный тест на стороне потребителя для прокси
класса KitchenServiceProxy, с помощью которого сервис Order обращается к сервису
Kitchen. Каждый тест шлет классу KitchenServiceProxy командное сообщение и про
веряет, возвращает ли тот ожидаемый результат. Он применяет Spring Cloud Contract,
чтобы сконфигурировать заглушки для сервиса Kitchen, которые ищут контракт, чье
входящее сообщение совпадает с командой, и затем отправляет свое исходящее сообще
ние в качестве ответа. Для упрощения и ускорения процесса тесты используют фиктив
ную инфраструктуру для обмена сообщениями, загруженную в оперативную память.
10.1. Написание интеграционных тестов 389
Листинг 10.9. Интеграционный тест на стороне потребителя для сервиса Order
@RunWith(SpringRunner.class)
@SpringBootTest(classes=
KitchenServiceProxylntegrationTest.TestConfiguration.class,
webEnvironment= SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(ids = ◄-------------------
{"net.chrisrichardson.ftgo.contracts:ftgo-kitchen-service-contracts"},
workOffline = false)
gDirtiesContext Настраиваем фиктивный сервис Kitchen,
public class KitchenServiceProxylntegrationTest { который будет отвечать на сообщения
^Configuration
@EnableAutoConfiguration
@Import({TramCommandProducerConfiguration.class,
TramlnMemoryConfiguration.class,
EventuateContractVerifierConfiguration.class})
public static class Testconfiguration { ... }
gAutowired
private SagaMessagingTestHelper sagaMessagingTestHelper;
@Autowired
private KitchenServiceProxy kitchenServiceProxy;
gTest
public void shouldSuccessfullyCreateTicket() {
CreateTicket command = new CreateTicket(A3ANTA_ID,
OrderDetailsMother.ORDER_ID,
new TicketDetails(Collections.singletonList(
new TicketLineItem(CHICKEN_VINDALOO_MENU_ITEM_ID,
CHICKEN_VINDALOO,
CHICKEN_VINDALOO_QUANTITY))));
String sagaType = CreateOrderSaga.class.getName();
CreateTicketReply reply =
sagaMessagingTestHelper ◄-
.sendAndReceiveCommand(kitchenServiceProxy.create,
command,
CreateTicketReply.class, sagaType);
assertEquals(new CreateTicketReply(OrderDetailsMother.ORDER_ID), reply);◄----- 1
Проверяем ответ |
}
Тестовый метод shouldSuccessfullyCreateTicket() отправляет командное
сообщение CreateTicket и проверяет, содержит ли ответ ожидаемые данные.
Он использует вспомогательный класс SagaMessagingTestHelper, чтобы отправлять
и получать сообщения синхронно.
Теперь посмотрим, как пишутся интеграционные тесты на стороне провайдера.
Отправляем команду
и ждем ответа
390 Глава 10 • Тестирование микросервисов, часть 2
Написание тестов на стороне провайдера с расчетом на потребителя
для взаимодействия в виде асинхронных запросов/ответов
Интеграционный тест на стороне провайдера должен убедиться в том, что в резуль
тате обработки командного сообщения провайдер возвращает корректный ответ.
Spring Cloud Contract генерирует тестовые классы с тестовым методом для каждого
контракта. Каждый тестовый метод шлет входящее сообщение контракта и прове
ряет, совпадает ли исходящее сообщение контракта с ответом.
Интеграционные тесты на стороне провайдера для сервиса Kitchen тестируют
класс KitchenServiceCommandHandler, обрабатывающий сообщения путем вызова
KitchenService. В листинге 10.10 показан класс AbstractKitchenServiceConsu-
merContractTest, который наследуют тесты, сгенерированные фреймворком Spring
Cloud Contract. Он создает экземпляр KitchenServiceCommandHandler с внедренным
макетом KitchenService.
Листинг 10.10. Родительский класс тестов на стороне провайдера, ориентированных
на потребителя и предназначенных для сервиса Kitchen
@RunWith(SpringRunner.class)
@SpringBootTest(classes =
AbstractKitchenServiceConsumerContractTest.TestConfiguration.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureMessageVerifier
public abstract class AbstractKitchenServiceConsumerContractTest {
^Configuration
@Import(RestaurantMessageHandlersConfiguration.class)
public static class Testconfiguration {
@Bean
public KitchenService kitchenService() { <
return mock(KitchenService.class);
}
}
gAutowired
private KitchenService kitchenService;
^Before
public void setup() {
reset(kitchenService);
when(kitchenService
.createTicket(eq(ll_), eq(lL), <
Заменяет определение
kitchenService @Веап-макетом
Настраивает макет так, чтобы тот
возвращал значения, совпадающие
с исходящим сообщением контракта
any(TicketDetails.class)))
.thenReturn(new Ticket(lL, IL,
new TicketDetails(Collections.emptyList())));
}
}
KitchenServiceCommandHandler вызывает KitchenService с аргументами, сформи
рованными на основе входящего сообщения контракта, и создает ответ, полученный
из возвращаемого значения. Метод тестового класса setup() конфигурирует фик-
10.2. Разработка компонентных тестов 391
тивный сервис Kitchen, чтобы тот возвращал значения, совпадающие с исходящим
сообщением контракта.
Интеграционные и модульные тесты проверяют поведение отдельных частей
сервиса. Интеграционные тесты позволяют убедиться в том, что сервисы могут
взаимодействовать со своими клиентами и зависимостями. Модульные тесты под
тверждают корректность логики сервиса. Но ни те ни другие не охватывают весь
сервис целиком. Чтобы проверить работу всего сервиса, мы продвинемся вверх по
пирамиде и посмотрим, как создаются компонентные тесты.
10.2. Разработка компонентных тестов
До сих пор мы обсуждали тестирование отдельных классов и их наборов. Но пред
ставьте, что нужно убедиться в надлежащей работе сервиса Order. Иными словами,
мы хотим написать приемочные тесты, которые работают с сервисом как с единым
целым и проверяют его поведение через его API. Для этого можно написать практи
чески сквозные тесты и развернуть сервис Order вместе со всеми его транзитивными
зависимостями. Но, как вы уже должны понимать, это медленный, ненадежный
и затратный подход.
Компонентное тестирование — гораздо лучший способ написания приемочных
тестов для сервисов. Как видно на рис. 10.6, компонентные тесты находятся между
интеграционными и сквозными. Их задача — проверка поведения отдельно взятого
сервиса. Они подменяют заглушками зависимости сервиса и симулируют их рабо
ту. Они могут даже использовать фиктивные версии таких компонентов, как базы
данных, размещая их в оперативной памяти.
Рис. 10.6. Компонентный тест тестирует отдельно взятый сервис. Обычно он применяет заглушки
для зависимостей сервиса
392 Глава 10 • Тестирование микросервисов, часть 2
Вначале я кратко продемонстрирую, как с помощью языка DSL с названием
Gherkin можно писать приемочные тесты для таких сервисов, как Order. После этого
мы обсудим многочисленные архитектурные трудности, присущие компонентному
тестированию. Затем я покажу, как написать приемочные тесты для сервиса Order.
Посмотрим, как выглядят приемочные тесты на языке Gherkin.
10.2.1. Определение приемочных тестов
Приемочные тесты относятся к бизнес-аспектам программного компонента. Они опи
сывают предпочтительное поведение, которое наблюдают его клиенты, игнорируя
внутреннюю реализацию. Эти тесты формируются на основе пользовательских
историй или сценариев. Например, Place Order — это одна из ключевых историй
сервиса Order:
Как у потребителя сервиса Order,
У меня должна быть возможность разместить заказ
Мы можем расширить ее до такого сценария:
Дано: действительный потребитель
Дано: действительная банковская карта
Дано: ресторан принимает заказы
Когда я заказываю виндалу из курицы в Ajanta
Тогда заказ должен быть принят
И должно быть опубликовано событие OrderAuthorized
Этот сценарий описывает желаемое поведение сервиса Order с точки зрения его
API.
Каждый сценарий определяет приемочный тест. Разделы Дано соответствуют
подготовительному этапу теста, раздел Когда соотносится с этапом выполнения,
а Тогда и И — с проверкой. Позже вам будет представлен тест для этого сценария,
который делает следующее.
1. Создает заказ, обращаясь к конечной точке POST /orders.
2. Проверяет состояние заказа, обращаясь к конечной точке GET /orders/{orderld}.
3. Подписывается на подходящий канал сообщений, чтобы проверить, опублико
вал ли сервис Order событие OrderAuthorized.
Мы могли бы перевести каждый сценарий в код на Java. Но более простым реше
нием будет создание приемочных тестов с помощью языка DSL, такого как Gherkin.
10.2.2. Написание приемочных тестов с помощью Gherkin
Написание приемочных тестов на Java сопровождается определенными трудностя
ми. Существует риск расхождения тестов и их сценариев. Также нет прямой связи
между высокоуровневыми сценариями и кодом, состоящим из низкоуровневых
подробностей реализации. К тому же некоторым тестам не хватает ясности, по-
10.2. Разработка компонентных тестов 393
этому их просто нельзя перевести в код на языке Java. Лучше сразу писать такие
сценарии, которые можно выполнять, — это позволит избавиться от ручного пре
образования.
Gherkin — это язык DSL для написания исполняемых спецификаций. При
емочные тесты на нем выглядят как сценарии на английском языке, подобные по
казанному ранее. Спецификация выполняется с помощью Cucumber — фреймворка
автоматизации тестирования для Gherkin. Два инструмента, Gherkin и Cucumber,
позволяют избежать ручного преобразования сценариев в запускаемый код.
Спецификация сервиса, такого как Order, состоит из возможностей. Каждая
возможность описывается в виде набора сценариев, похожих на тот, что вы видели
ранее. Сценарий имеет структуру «дано — когда — тогда». «Дано» — это предвари
тельные условия, «когда» — это действие или происходящее событие, а «тогда/и» —
ожидаемый результат.
Например, желаемое поведение сервиса Order определяется несколькими воз
можностями, такими как Place Order, Cancel Order и Revise Order. В листинге 10.11
представлен фрагмент возможности Place Order, которая состоит из нескольких
элементов:
□ названия — в данном случае это Place Order;
□ краткого описания спецификации — описывает назначение возможности. В дан
ном случае указана пользовательская история;
□ сценариев Order authorized (заказ авторизован) и Order rejected due to expired
credit card (заказ отклонен из-за просроченной банковской карты).
Листинг 10.11. Определение возможности Place Order и некоторые ее сценарии на языке Gherkin
Feature: Place Order
As a consumer of the Order Service
I should be able to place an order
Scenario: Order authorized
Given a valid consumer
Given using a valid credit card
Given the restaurant is accepting orders
When I place an order for Chicken Vindaloo at Ajanta
Then the order should be APPROVED
And an OrderAuthorized event should be published
Scenario: Order rejected due to expired credit card
Given a valid consumer
Given using an expired credit card
Given the restaurant is accepting orders
When I place an order for Chicken Vindaloo at Ajanta
Then the order should be REJECTED
And an OrderRejected event should be published
394 Глава 10 • Тестирование микросервисов, часть 2
В обоих сценариях клиент пытается разместить заказ. В первом случае это уда
ется. Во втором заказ отклоняется, потому что у клиента просрочена банковская
карта. Больше информации о языке Gherkin можно найти в книге Камиля Ничей
(Kamil Nicieja) Writing Great Specifications: Using Specification by Example and Gherkin
(Manning, 2017).
Выполнение спецификации формата Gherkin с помощью Cucumber
Cucumber — это фреймворк для автоматического тестирования, выполняющий
тесты, написанные на Gherkin. Он доступен для разных языков, включая Java.
При использовании его в Java вы должны написать класс пошагового определения
(листинг 10.12). Класс пошагового определения состоит из методов, которые опреде
ляют значение каждого из шагов в цепочке «дано — тогда — когда». Каждый метод
имеет одну из следующих аннотаций: @Given, @When, @Then или @And. У всех аннотаций
есть элемент value, он содержит регулярное выражение, которое Cucumber сопо
ставляет с шагами.
Листинг 10.12. Класс пошагового определения на Java делает сценарии Gherkin исполняемыми
public class StepDefinitions ... {
@Given("A valid consumer”)
public void useConsumer() { ... }
@Given("using a(.?) (.*) credit card")
public void useCreditCard(String ignore, String creditcard) { ... }
@When("I place an order for Chicken Vindaloo at Ajanta")
public void placeOrder() { ... }
@Then(”the order should be (.*)")
public void theOrderShouldBe(String desiredOrderState) { ... }
@And("an (.*) event should be published")
public void verifyEventPublished(String expectedEventClass) { ... }
}
Каждый из типов методов является частью определенного этапа теста:
□ @Given — подготовительный этап;
□ @When — этап выполнения;
□ @Then и @And — этап проверки.
В подразделе 10.2.4, когда я опишу этот класс подробнее, вы увидите, что мно
гие из упомянутых методов шлют REST-вызовы сервису Order. Например, метод
10.2. Разработка компонентных тестов 395
placeOrder() создает заказ, обращаясь к конечной точке POST /orders. Метод theOr-
derShouldBe() проверяет состояние заказа, делая вызов GET /orders/ {orderld}.
Прежде чем приступать к написанию пошаговых классов, исследуем некоторые
архитектурные проблемы, присущие компонентным тестам.
10.2.3. Проектирование компонентных тестов
Представьте, что вы пишете компонентный тест для сервиса Order. В предыдущем
подразделе было показано, как определить желаемое поведение на языке Gherkin
и выполнить его с помощью Cucumber. Но перед выполнением сценария ком
понентный тест должен запустить сервис Order и подготовить его зависимости.
Мы выполняем изолированное тестирование, поэтому компонентному тесту нужно
сконфигурировать заглушки для нескольких сервисов, включая Kitchen. Ему также
необходимо предоставить базу данных и инфраструктуру обмена сообщениями.
Существует несколько решений, которые позволяют выбирать между реализмом
и скоростью/простотой.
Компонентные тесты внутри процесса
Одно из решений состоит в написании внутрипроцессных компонентных тестов,
которые подменяют зависимости сервиса заглушками и макетами, загружаемыми
в оперативную память. Например, вы можете задействовать фреймворк Spring Boot
для написания как самого сервиса, так и его компонентных тестов. Тестовый класс,
помеченный аннотацией @SpringBootTest, запускает сервис на той же JVM-машине,
что и сам тест. Он внедряет зависимости, чтобы заставить сервис обращаться к маке
там и заглушкам. Например, тест может сконфигурировать сервис Order так, чтобы
тот использовал резидентную базу данных типа JDBC, такую как Н2, HSQLDB
или Derby, и резидентные заглушки для Eventuate Tram. Внутрипроцессные тесты
более быстрые и простые в написании. Их слабая сторона связана с тем, что они
не тестируют развертываемый сервис.
Компонентное тестирование за пределами процесса
Более реалистичным решением будет упаковать сервис в формат, готовый к про
мышленному применению, и запустить его в виде отдельного процесса. Например,
в главе 12 пойдет речь о том, что упаковка сервисов в качестве образов контейнеров
для Docker становится все более популярной. Внепроцессный компонентный тест
задействует настоящую инфраструктуру, включая базу данных и брокер сообщений,
но при этом подменяет заглушками все зависимости, которые являются сервисами
приложения. Например, внепроцессный компонентный тест для сервиса Order
будет использовать MySQL и Apache Kafka, заменяя заглушками сервисы Comsumer
и Accounting. Заглушки будут потреблять сообщения, полученные от Apache Kafka,
и отсылать обратно свои ответы.
396 Глава 10 • Тестирование микросервисов, часть 2
Ключевое преимущество внепроцессного компонентного тестирования — более
широкое покрытие тестов, поскольку тестируемые компоненты значительно мень
ше отличаются от кода, который будет развертываться. Недостаток этих тестов по
сравнению с внутрипроцессными в том, что они сложнее в написании, выполняются
медленнее и могут оказаться не такими надежными. К тому же вам нужно разо
браться с тем, как подменять сервисы приложения. Посмотрим, как это делается.
Как подменить сервисы во внепроцессных тестах
Тестируемый сервис часто взаимодействует со своими зависимостями в стиле,
который подразумевает возвращение ответа. Сервис Order, к примеру, использу
ет асинхронные запросы/ответы и рассылает командные сообщения различным
сервисам. API-шлюз применяет протокол HTTP, который работает по принципу
«запрос/ответ». Внепроцессный тест должен сконфигурировать заглушки для этих
зависимостей так, чтобы они обрабатывали запросы и возвращали ответы.
Одно из решений — применение фреймворка Spring Cloud Contract, с которым
мы познакомились в разделе 10.1 при обсуждении интеграционного тестирования.
Мы могли бы написать контракты, которые конфигурируют заглушки для компо
нентных тестов. При этом необходимо понимать, что эти контракты, в отличие от
интеграционных, будут использоваться лишь компонентными тестами.
Еще один недостаток фреймворка Spring Cloud Contract в контексте компо
нентного тестирования состоит в том, что он сосредоточен на тестировании потре
бительских контрактов, поэтому используемый им подход довольно тяжеловесен.
J AR-файлы с контрактами внутри должны быть развернуты в репозитории Maven —
их недостаточно просто добавить в classpath. Управление взаимодействием, в кото
ром применяются динамически генерируемые значения, тоже сопряжено с опре
деленными трудностями. Поэтому более простым вариантом будет конфигурация
заглушек прямо из теста.
Тест, к примеру, может сконфигурировать HTTP-заглушку с помощью специ
ального языка WireMock, который называется WireMock. Тест сервиса, использу
ющего Eventuate Tram, может аналогичным образом симулировать инфраструктуру
для обмена сообщениями. Позже в этом разделе я покажу простую в применении
библиотеку, которая этим занимается.
Разобравшись с тем, как проектировать компонентные тесты, может перейти
к их созданию.
10.2.4. Написание компонентных тестов
для сервиса Order
Как вы уже видели в этом разделе, компонентные тесты можно реализовать несколь
кими разными способами. Здесь мы обсудим компонентные тесты для сервиса Order,
запущенного в виде контейнера Docker. Они будут задействовать внепроцессную
стратегию. Вы увидите, как они будут запускать и останавливать контейнер Docker
10.2. Разработка компонентных тестов 397
с помощью дополнения Gradle. Я также покажу, как использовать Cucumber для
выполнения сценариев на языке Gherkin, которые описывают желаемое поведение
сервиса Order.
Структура компонентных тестов для сервиса Order показана на рис. 10.7. Тесто
вый класс OrderServiceComponentTest запускает Cucumber:
gRunWith(Cucumber.class)
gCucumberOptions(features = "src/component-test/resources/features”)
public class OrderServiceComponentTest {
}
Рис. 10.7. Компонентные тесты для сервиса Order с помощью тестового фреймворка Cucumber
выполняют тестовые сценарии, написанные на языке DSL для приемочного тестирования Gherkin.
Тесты применяют Docker для выполнения сервиса Order и его инфраструктурных компонентов,
таких как Apache Kafka и MySQL
Он имеет аннотацию @CucumberOptions, которая задает местоположение файлов
с возможностями для Gherkin. Также он помечен аннотацией @RunWith(Cucum-
ber. class), которая заставляет JUNIT использовать средство выполнения тестов
из состава Cucumber. Но, в отличие от тестовых классов, основанных на JUNIR,
этот класс не содержит никаких тестовых методов. Для определения тестов он
считывает возможности Gherkin и делает их исполняемыми с помощью класса
OrderServiceComponentTestStepDefinitions.
398 Глава 10 • Тестирование микросервисов, часть 2
Применение Cucumber в сочетании с фреймворком тестирования Spring Boot
требует немного необычной структуры. Класс OrderServiceComponentTestStepDe-
f initions не тестовый, но все равно помечен аннотацией gContextConf iguration из
состава фреймворка тестирования Spring. Он создает контекст Spring-приложения,
Applicationcontext, который определяет различные компоненты Spring, включая
фиктивную инфраструктуру для обмена сообщениями. Давайте подробно рассмо
трим пошаговые определения.
Класс OrderServiceComponentTestStepDefinitions
В основе тестов лежит класс OrderServiceComponentTestStepDef initions. Он опреде
ляет значение каждого шага в компонентных тестах сервиса Order. В листинге 10.13
показан метод usingCreditCardQ, который определяет значение шага Given using
... credit card.
Листинг 10.13. Метод @GivenuseCreditCard() определяет значение шага Given using ... credit card
@ContextConfiguration(classes =
OrderServiceComponentTestStepDefinitions.Testconfiguration.class)
public class OrderServiceComponentTestStepDefinitions {
gAutowired
protected SagaParticipantStubManager sagaParticipantStubManager;
@Given("using a(.?) (.♦) credit card")
public void useCreditCard(String ignore, String creditcard) {
if (creditcard.equals("valid")) i B
sagaParticipantStubManager «----------- 1 Возвращает успешный ответ
.forChannel("accountingservice")
.when(AuthorizeCommand.class).replyWithSuccess();
else if (creditcard.equals(”invalid")) | £ооб1цение об отказе
sagaParticipantStubManager ◄----------- 1
.forChannel("accountingservice")
.when(AuthorizeCommand.class).replyWithFailure();
else
fail("Don't know what to do with this credit card");
}
Этот метод использует вспомогательный класс SagaParticipantStubManager,
который конфигурирует заглушки для участников повествования. С его помощью
метод useCreditCard() подготавливает фиктивный сервис Accounting, который бу
дет возвращать сообщение об успешном выполнении или отказе в зависимости от
указанной банковской карты.
В листинге 10.14 представлен метод placeOrder(), определяющий шаг When
I place an order for Chicken Vindaloo at Ajanta. Он обращается к REST API сервиса
Order, чтобы создать заказ, и сохраняет ответ для последующей проверки.
10.2. Разработка компонентных тестов 399
Листинг 10.14. Метод placeOrder() определяет шаг When I place an order for Chicken Vindaloo at Ajanta
@ContextConfiguration(classes =
OrderServiceComponentTestStepDefinitions.Testconfiguration.class)
public class OrderServiceComponentTestStepDefinitions {
private int port = 8082;
private String host = System.getenv(”DOCKER_HOST_IP");
protected String baseUrl(String path) {
return String.format("http://%s:%s%s"j host, port, path);
}
private Response response;
@When("I place an order for Chicken Vindaloo at Ajanta")
public void placeOrder() { Обращается к REST API сервиса Order,
чтобы создать заказ
response = given(). ◄------------------
body(new CreateOrderRequest(consumerId,
RestaurantMother.AJANTA_IDj Collections.singletonList(
new CreateOrderRequest.Lineltem(
RestaurantMother.CHICKEN J/INDALOO_MENU_ITEM_ID,
OrderDetailsMother.CHICKEN_VINDALOO_QUANTITY)) )).
contentType("application/json").
when().
post(baseUrl("/orders"));
}
Вспомогательный метод baseUrl() возвращает URL-адрес сервиса Order.
В листинге 10.15 показан метод theOrderShouldBe(), который определяет зна
чение шага Then the order should be.... Он проверяет успешность заказа и то, на
ходится ли он в ожидаемом состоянии.
Листинг 10.15. Метод @ThentheOrderShouldBe() проверяет, был ли НТТР-запрос успешным
@ContextConfiguration(classes =
OrderServiceComponentTestStepDefinitions.Testconfiguration.class)
public class OrderServiceComponentTestStepDefinitions {
@Then("the order should be (.♦)")
public void theOrderShouldBe(String desiredOrderState) {
Integer orderld = 4-----------
this, response. then(). statusCode(200). Проверяет успешность
extract (). path ("orderld"); создания заказа
assertNotNull(orderld);
eventually(() -> {
String state = given().
when().
get(baseUrl("/orders/" + orderld)).
400 Глава 10 • Тестирование микросервисов, часть 2
then().
statusCode(200)
.extract().
u. 4. ч I Проверяет состояние заказа
assertEquals(desiredOrderState, state); ◄----- 1
});
}
]
Утверждение об ожидаемом состоянии завернуто в вызов eventually(), который
выполняет его несколько раз.
В листинге 10.16 можно видеть метод verifyEventPublished(), который опреде
ляет шаг And ап ... event should be published. Он проверяет, было ли опубликовано
ожидаемое событие.
Листинг 10.16. Класс пошагового определения Cucumber для компонентных тестов сервиса Order
@ContextConfiguration(classes =
OrderServiceComponentTestStepDefinitions.TestConfiguration.class)
public class OrderServiceComponentTestStepDefinitions {
@Autowired
protected MessageTracker messageTracker;
@And(”an (.*) event should be published”)
public void verifyEventPublished(String expectedEventClass) throws ClassNot
FoundException {
messageTracker.assertDomainEventPublished("net.chrisrichardson.ftgo.order
service.domain.Order”,
(Class<DomainEvent>)Class.forName("net.chrisrichardson.ftgo.order
service.domain.” + expectedEventClass));
}
J
Метод verifyEventPublished() использует вспомогательный тестовый класс
MessageTracker, который записывает события, опубликованные во время те
ста. Экземпляры этого класса и SagaParticipantStubManager создаются классом
Testconfiguration ^Configuration.
Мы рассмотрели пошаговое определение. Теперь можно перейти к выполнению
компонентных тестов.
Выполнение компонентных тестов
Эти тесты довольно медленные, поэтому их не стоит выполнять в рамках команды
. /gradlew test. Вместо этого разместим их код в отдельном каталоге src/component-
test/java и будем запускать их как . /gradlew componentTest. Конфигурация Gradle
находится в файле ftgo-order-service/build.gradle.
10.3. Написание сквозных тестов 401
Для запуска сервиса Order и его зависимостей тесты используют Docker. Как вы
узнаете из главы 12, контейнер Docker представляет собой легковесный механизм
виртуализации операционной системы, который позволяет развертывать экземпля
ры сервисов в изолированной среде. Чрезвычайно полезным инструментом является
Docker Compose, с помощью которого можно определить набор контейнеров и запу-
скать/останавливать их как единое целое. В корневом каталоге приложения FTGO
находится файл docker-compose, который описывает контейнеры для всех сервисов
и инфраструктурных компонентов.
Мы можем воспользоваться дополнением Docker Compose для Gradle, чтобы
запускать контейнеры перед выполнением тестов и останавливать их после завер
шения тестирования:
apply plugin: 'docker-compose'
dockerCompose.isRequiredBy(componentTest)
componentTest.dependsOn(assemble)
dockerCompose {
startedServices = [ 'ftgo-order-service']
}
Приведенный фрагмент конфигурации Gradle делает две вещи. Во-первых, он
настраивает дополнение Gradle Docker Compose для выполнения компонентных
тестов и запуска сервиса Order вместе с инфраструктурными компонентами, от
которых он зависит согласно конфигурации. Во-вторых, он делает так, чтобы тест
componentTest зависел от этапа assemble. Благодаря этому будет предварительно
собираться JAR-файл, который нужен образу Docker. Подготовив все это, мы может
запустить компонентные тесты с помощью следующих команд:
./gradlew :ftgo-order-service:componentTest
Эти команды работают несколько минут и выполняют следующие действия.
1. Сборка сервиса Order.
2. Запуск сервиса и его инфраструктурных зависимостей.
3. Запуск тестов.
4. Остановка запущенных сервисов.
Теперь вы знаете, как писать тесты для отдельных сервисов. Пришло время про
тестировать приложение целиком.
10.3. Написание сквозных тестов
Компонентные тесты тестируют каждый сервис по отдельности. Сквозные тесты
тестируют приложение целиком. Сквозные тесты находятся на вершине пирамиды
тестов (рис. 10.8), так как они (повторяйте за мной) медленные и ненадежные, а на
их разработку уходит много времени.
402 Глава 10 • Тестирование микросервисов, часть 2
Рис. 10.8. Сквозные тесты располагаются на вершине пирамиды тестирования. Они медленные
и ненадежные, а на их разработку затрачивается много времени. Количество сквозных тестов
следует минимизировать
Сквозные тесты состоят из множества элементов. Они требуют развертывания
большого количества сервисов и инфраструктурных компонентов, что их замедляет.
К тому же, если для работы теста нужно развернуть большое количество сервисов,
вполне вероятно, что развертывание одного из них окажется неудачным, из-за чего
тест окажется ненадежным. Так что вы должны свести количество сквозных тестов
к минимуму.
10.3.1. Проектирование сквозных тестов
Как я уже объяснил, следует писать как можно меньше таких тестов. Лучше сделать
так, чтобы они были основаны на пользовательских путешествиях, которые описы
вают перемещения пользователя по системе. Например, проверку создания, пере
смотра и отмены заказа можно объединить в один тест. Этот подход существенно
уменьшает количество тестов, которые вам нужно написать, и сокращает время их
выполнения.
10.3.2. Написание сквозных тестов
Сквозные тесты, как и приемочное тестирование, рассмотренное в разделе 10.2,
сосредоточены на бизнес-аспектах приложения. Их лучше писать на высокоуров
невом языке DSL, доступном для понимания людям, которые занимаются бизнес-
процессами. Вы можете, к примеру, создавать сквозные тесты на языке Gherkin
и выполнять их с помощью Cucumber. Это продемонстрировано в листинге 10.17.
Код напоминает приемочные тесты, которые мы рассматривали ранее. Основное
отличие состоит в том, что вместо одного выражения Then здесь имеется несколько
действий.
Резюме 403
Листинг 10.17. Спецификация пользовательского путешествия на языке Gherkin
Feature: Place Revise and Cancel
As a consumer of the Order Service
I should be able to place, revise, and cancel an order
Scenario: Order created, revised, and cancelled
Given a valid consumer
Given using a valid credit card
Given the restaurant is accepting orders
When I place an order for Chicken Vindaloo at Ajanta
Then the order should be APPROVED
_ _ | Создать заказ
Then the order total should be 16.33
And when I revise the order by adding 2 vegetable samosas
Then the order total should be 20.97
^1 Пересмотреть заказ
And when I cancel the order
Then the order should be CANCELLED
_ _ | Отменить заказ
Этот сценарий размещает заказ, пересматривает и затем отменяет его. Посмо
трим, как его выполнить.
10.3.3. Выполнение сквозных тестов
Сквозные тесты должны запускать целое приложение, включая все инфраструктур
ные компоненты. Как вы видели в разделе 10.2, дополнение Gradle Docker Compose
предоставляет удобный способ сделать это. Однако вместо выполнения отдельных
сервисов приложения файл Docker Compose должен запустить их все.
Мы уже познакомились с разными аспектами проектирования и написания
сквозных тестов. Теперь рассмотрим конкретный пример.
Сквозные тесты для приложения FTGO находятся в модуле ftgo-end-to-end-
test. Их реализация похожа на код компонентных тестов, рассмотренных в раз
деле 10.2. Они написаны на языке Gherkin и выполняются с помощью Cucumber.
Перед выполнением тестов дополнение Gradle Docker Compose запускает контей
неры. На все это уходит 4-5 мин.
На первый взгляд может показаться, что это не так уж и долго, но мы имеем
дело с относительно простым приложением, которое состоит из горстки контейне
ров и тестов. Представьте, что бы было, если бы количество контейнеров и тестов
исчислялось сотнями! Тестирование могло бы занять довольно много времени.
Поэтому лучше сосредоточиться на написании тестов, находящихся на более низких
ступенях пирамиды.
Резюме
□ Используйте контракты (примеры сообщений) для тестирования взаимодействия
между сервисами. Пишите тесты, которые проверяют адаптеры сервисов на соот
ветствие контрактам, не запуская сами сервисы и их зависимости.
404 Глава 10 • Тестирование микросервисов, часть 2
□ Для проверки поведения сервиса через его API пишите компонентные тесты.
Для их упрощения и ускорения сервисы следует тестировать отдельно друг от
друга, задействуя заглушки вместо зависимостей.
□ Сквозные тесты медлительны и ненадежны, а поэтому отнимают много времени.
Чтобы свести их количество к минимуму, пишите их на основе пользователь
ских путешествий. Пользовательское путешествие симулирует перемещения
пользователя по приложению и проверяет высокоуровневое поведение довольно
крупных аспектов функциональности. Чем меньше тестов, тем меньше накладных
расходов (например, на их подготовку), что ускоряет тестирование.
Разработка сервисов,
готовых к промышленному
использованию
Мэри и ее команда, по их собственным ощущениям, овладели такими аспектами
разработки, как декомпозиция сервисов, межсервисное взаимодействие, управление
транзакциями, проектирование запросов и бизнес-логики и тестирование. Они были
уверены в том, что им по силам разработать сервисы, удовлетворяющие их функ
циональным требованиям. Однако сервис можно считать готовым к развертыванию
только в том случае, если ему присущи три критически важные качественные харак
теристики: безопасность, конфигурируемость и наблюдаемость.
Первой качественной характеристикой является безопасность приложения.
Если вы не хотите обнаружить название своей компании в новостных заголовках,
406 Глава 11 • Разработка сервисов, готовых к промышленному использованию
сообщающих об утечке данных, ваше приложение должно быть безопасным.
К счастью, с точки зрения безопасности микросервисная архитектура и монолитное
приложение имеют много общего. Команда FTGO знала, что многие навыки, приоб
ретенные в ходе разработки монолита, пригодятся и в работе над микросервисами.
Однако некоторые аспекты безопасности на уровне приложения все же различаются.
Например, в микросервисной архитектуре необходимо реализовать механизм для
передачи пользовательских данных от одного сервиса к другому.
Вторая качественная характеристика, которую вы должны учитывать, — конфигу
рируемость сервисов. Сервис обычно имеет одну или несколько внешних зависимо
стей, таких как брокеры сообщений или базы данных. Сетевое размещение и учетные
данные каждого внешнего компонента зависят от того, в какой среде выполняется
сервис. Вы не можете хранить конфигурационные свойства прямо в его коде. Вместо
этого должны использовать внешний механизм, который предоставляет сервису
конфигурацию на этапе выполнения.
Третья качественная характеристика — наблюдаемость. Команда FTGO реали
зовала мониторинг и ведение журнала для существующего приложения. Однако
распределенность микросервисной архитектуры создает дополнительные сложно
сти. Каждый запрос обрабатывается API-шлюзом и как минимум одним сервисом.
Представьте, к примеру, что вы пытаетесь определить, какой из шести сервисов при
водит к проблемам с латентностью. Или что вам нужно разобраться в том, как обра
батывается запрос, когда журнальные записи разбросаны по пяти разным сервисам.
Чтобы было легче понимать поведение приложения и диагностировать проблемы,
вы должны реализовать несколько шаблонов наблюдаемости.
Я начну эту главу с описания того, как обеспечить безопасность в микросервис
ной архитектуре. Затем мы обсудим проектирование конфигурируемых сервисов.
Будут рассмотрены несколько механизмов конфигурации. После этого мы погово
рим о том, как сделать ваши сервисы более простыми для понимания и диагностики
с помощью шаблонов наблюдаемости. В конце я продемонстрирую простую реа
лизацию этих и других аспектов на примере сервисов, основанных на фреймворке
микросервисного шасси.
Начнем с безопасности.
11.1. Разработка безопасных сервисов
Кибербезопасность превратилась в насущную проблему для любой организации.
Почти каждый день в новостях можно видеть сообщения о том, как хакеры похитили
данные компании. Чтобы разрабатывать безопасное программное обеспечение и не
попадать в новостные ленты, организация должна решить широкий диапазон про
блем, связанных с безопасностью, включая физическую безопасность оборудования,
шифрование данных в процессе передачи и при хранении, аутентификацию и авто
ризацию, а также политику исправления программных уязвимостей. Большинство
этих проблем в равной степени относятся как к монолитной, так и к микросервисной
архитектуре. Этот раздел посвящен тому, как микросервисы влияют на безопасность
всего приложения.
11.1. Разработка безопасных сервисов 407
Разработчик в первую очередь несет ответственность за то, как реализованы
четыре аспекта безопасности.
□ Аутентификация. Проверяет подлинность программы или человека (субъекта
безопасности), которые пытаются получить доступ к приложению. Приложение
обычно проверяет учетные данные субъекта, такие как ID и пароль или API-ключ
и секретный токен.
□ Авторизация. Проверяет, позволено ли субъекту выполнять запрошенную опе
рацию с заданными данными. Приложения часто применяют безопасность на
основе ролей в сочетании со списками управления доступом (Access Control List,
ACL). Каждый пользователь получает одну или несколько ролей, которые дают
им право вызывать определенные операции. Списки ACL разрешают пользо
вателям или ролям выполнять операции с определенным бизнес-объектом или
агрегатом.
□ Аудит. Отслеживает операции, выполняемые субъектом, чтобы обнаруживать
проблемы с безопасностью, помогать службе поддержки и обеспечивать соблю
дение нормативно-правовых норм.
□ Безопасное межпроцессное взаимодействие. В идеале любое взаимодействие вну
три сервисов и за их пределами должно производиться поверх TLS (Transport
Layer Security — протокол защиты транспортного уровня). Для межпроцессного
взаимодействия может даже понадобиться аутентификация.
Аудит подробно описывается в разделе 11.3, а защиту межпроцессного взаи
модействия мы затронем в ходе обсуждения сетей сервисов в подразделе 11.4.1.
Здесь же сосредоточимся на реализации аутентификации и авторизации.
Вначале я объясню, как безопасность была реализована в монолитной версии
приложения FTGO. Затем перечислю трудности, возникающие при обеспечении
безопасности в микросервисной архитектуре, и методики, которые хорошо подходят
для монолита, но не годятся для микросервисов. После этого мы займемся реализа
цией безопасности в микросервисной архитектуре.
Начнем с того, как обеспечивается безопасность в монолитной версии FTGO.
11.1.1. Обзор безопасности в традиционном
монолитном приложении
У приложения FTGO есть несколько разновидностей живых пользователей — кли
енты, курьеры и работники ресторанов. Для доступа к приложению они используют
браузерный и мобильный веб-интерфейсы. Все пользователи FTGO должны вначале
войти в систему. На рис. 11.1 показано, как аутентифицируются и выполняют за
просы клиенты монолитного приложения FTGO.
Когда пользователь входит в систему со своими идентификатором и паролем,
клиент отправляет приложению FTGO POST-запрос, содержащий его учетные дан
ные. Приложение проверяет эти данные и возвращает клиенту токен сеанса. Клиент
включает этот токен во все свои последующие запросы.
408 Глава 11 • Разработка сервисов, готовых к промышленному использованию
Рис. 11.1. Вначале клиент приложения FTGO входит в систему, чтобы получить токен сеанса,
в качестве которого часто выступает cookie. Клиент включает этот токен в каждый последующий
запрос, отправляемый приложению
На рис. 11.2 представлены общие принципы обеспечения безопасности в при
ложении FTGO. Оно написано на Java с применением фреймворка Spring Security,
но для описания его архитектуры я буду использовать общие понятия, пригодные
и для других технологий, таких как Passport для NodeJS.
11.1. Разработка безопасных сервисов 409
410 Глава И • Разработка сервисов, готовых к промышленному использованию
Ключевую роль в архитектуре безопасности играет сеанс, который хранит ID
и роль субъекта. FTGO — это традиционное приложение на основе Java ЕЕ, поэтому
сеанс представлен объектом HttpSession, находящимся в оперативной памяти. Сеанс
идентифицируется соответствующим токеном, который клиент включает в каждый
запрос. Обычно это непрозрачное значение наподобие надежно зашифрованного
случайного числа. Токен сеанса в приложении FTGO имеет вид HTTP-cookie
JSESSIONID.
Еще одним ключевым аспектом безопасности является контекст, который хра
нит информацию о пользователе, выполняющем текущий запрос. Фреймворк Spring
Security использует стандартный для Java ЕЕ подход, храня контекст в статической
переменной, локальной для потока приложения. К ней легко может обратиться
любой код, вовлеченный в обработку запроса. Чтобы получить информацию о те
кущем пользователе, такую как идентификатор и роль, обработчик может вызвать
SecurityContextHolder. getContext (). getAuthentication(). Для сравнения: фрейм
ворк Passport хранит контекст безопасности в атрибуте запроса user.
Последовательность событий, представленных на рис. 11.2, выглядит так.
1. Клиент шлет приложению FTGO запрос на вход в систему.
2. Запрос входа в систему обрабатывается объектом LoginHandler, который про
веряет учетные данные, создает сеанс и сохраняет туда информацию о субъекте.
3. LoginHandler возвращает клиенту токен сеанса.
4. Клиент включает токен сеанса в запросы, выполняющие операции.
5. Эти запросы вначале обрабатываются перехватчиком SessionBasedSecuri-
tylnterceptor. Он аутентифицирует каждый запрос, проверяя токен сеанса,
и устанавливает контекст безопасности. Контекст безопасности описывает субъ
ект и его роли.
6. С помощью контекста безопасности обработчик запросов определяет, разреше
но ли пользователю выполнять запрошенную операцию, и получает его уникаль
ный идентификатор.
Приложение FTGO использует авторизацию на основе ролей. Оно поддерживает
несколько ролей, которые относятся к разным категориям пользователей: CONSUMER,
RESTAURANT, COURIER и ADMIN. А еще применяет декларативный механизм безопасно
сти из состава Spring Security, чтобы ограничить доступ к URL-адресам и методам
сервисов для определенных ролей. Кроме того, роли интегрированы в бизнес-логику.
Например, клиенты видят только свои заказы, тогда как у администраторов есть
доступ к заказам всех пользователей.
Данная архитектура — лишь один из способов обеспечения безопасности в моно
литной версии приложения FTGO. К примеру, из-за того, что сеансы хранятся
в оперативной памяти, все запросы в рамках отдельного сеанса должны быть на
правлены к одному и тому же экземпляру приложения. Это требование усложняет
балансирование нагрузки и администрирование. Например, это требует реализации
11.1. Разработка безопасных сервисов 411
дренажного механизма, который, прежде чем остановить сервер с экземпляром при
ложения, ждет истечения срока действия всех его сеансов. Чтобы избежать этой
проблемы, сеансы можно хранить в базе данных.
В некоторых случаях от сеансов на стороне сервера можно полностью избавить
ся. Например, клиенты многих приложений предоставляют свои учетные данные,
такие как API-ключ и секретный токен, в каждом API-запросе. Благодаря этому
отпадает необходимость в поддержании сеанса на серверной стороне. Как вариант,
приложение может хранить состояние сеанса в его токене. Позже я покажу один из
способов, как это можно сделать. Но для начала рассмотрим трудности обеспечения
безопасности в микросервисной архитектуре.
11.1.2. Обеспечение безопасности
в микросервисной архитектуре
Микросервисные приложения являются распределенными. Каждый запрос обраба
тывается API-шлюзом и как минимум одним сервисом. Возьмем, к примеру, запрос
getOrderDetails(), который мы обсуждали в главе 8. Для его обработки API-шлюз
обращается к нескольким сервисам, таким как Order, Kitchen и Accounting. Каждый
из них должен реализовать некоторые аспекты безопасности. Скажем, сервис Order
должен возвращать клиентам только их собственные заказы, что требует сочетания
аутентификации и авторизации. Чтобы обеспечить безопасность в микросервисной
архитектуре, нам нужно определиться с тем, кто отвечает за аутентификацию поль
зователя, а кто — за его авторизацию.
Одна из сложностей реализации безопасности в микросервисном приложении
связана с тем, что мы не можем просто скопировать соответствующие решения из
монолитной архитектуры. Это происходит из-за того, что механизмы безопасности
в монолитных приложениях имеют два аспекта, которые совершенно не подходят
для микросервисов.
□ Контекст безопасности в оперативной памяти. Хранение контекста безопасно
сти в оперативной памяти, например внутри потока, для раздачи данных о поль
зователе. Сервисы не способны разделять память, поэтому они не могут исполь
зовать подобного рода механизм. Микросервисная архитектура требует другого
подхода к передаче пользовательских данных от одного сервиса к другому.
□ Централизованный сеанс. Поскольку контекст безопасности нельзя размещать
в памяти, это ограничение распространяется и на сеанс. Теоретически разные
сервисы могли бы получать доступ к сеансу, который хранится в базе данных, но
это нарушило бы принцип слабой связанности. Для микросервисной архитекту
ры нужен другой механизм сеансов.
Начнем исследование безопасности в микросервисной архитектуре с рассмотре
ния аутентификации.
412 Глава 11 • Разработка сервисов, готовых к промышленному использованию
Выполнение аутентификации в API-шлюзе
Аутентификацию пользователей можно реализовать несколькими способами.
Например, эту функцию могут взять на себя отдельные сервисы. Проблема этого
подхода в том, что он допускает попадание неаутентифицированных запросов во
внутреннюю сеть. К тому же каждая команда разработчиков должна обеспечить
надлежащую безопасность своих сервисов. В итоге существенно возрастает риск
возникновения уязвимостей.
Еще одна проблема выполнения аутентификации на уровне сервисов связана
с тем, что разные клиенты могут по-разному себя аутентифицировать. Клиенты,
работающие исключительно через API, предоставляют учетные данные в каждом
запросе (так, например, делается при HTTP-аутентификации). Другие клиенты
могут сначала войти в систему, а затем прилагать токен сеанса к каждому вызову.
Мы не хотим, чтобы сервисы отвечали за поддержку разнообразных механизмов
аутентификации.
Лучше сделать так, чтобы любой запрос, прежде чем попасть к сервису, аутен
тифицировался API-шлюзом. Благодаря такому централизованному подходу мы
можем сосредоточиться на одном участке приложения, что существенно снижает
риск возникновения уязвимостей. Еще одно преимущество состоит в том, что за
работу с разными механизмами аутентификации отвечает лишь API-шлюз. Сервисы
ограждены от всех этих нюансов.
Принцип работы этого подхода показан на рис. 11.3. Клиенты аутентифициру
ются API-шлюзом и включают свои учетные данные в каждый запрос. Клиенты,
которым нужно сначала войти в систему, шлют API-шлюзу сведения о пользователе
методом POST, получая в ответ токен сеанса. Аутентифицировав запрос, API-шлюз
обращается к одному или нескольким сервисам.
Сервис, к которому обратился API-шлюз, должен опознать субъекта, выполня
ющего запрос. Он также должен проверить, был ли этот запрос аутентифицирован.
Для этого при каждом обращении к сервису API-шлюз указывает токен. С помощью
токена сервис проверяет подлинность запроса и извлекает информацию о субъекте.
API-шлюз может выдавать этот токен и клиентам, ориентированным на сеансы,
в этом случае он становится токеном сеанса.
Для API-клиентов последовательность событий выглядит так.
1. Клиент делает запрос, содержащий учетные данные.
2. API-шлюз аутентифицирует учетные данные, создает токен безопасности и пере
дает его сервису (-ам).
11.1. Разработка безопасных сервисов 413
Р
и
с.
1
1.
3.
A
PI
-ш
лю
з
по
лу
ча
ет
к
ли
ен
тс
ки
е
за
пр
ос
ы
и
у
ка
зы
ва
ет
т
ок
ен
б
ез
оп
ас
но
ст
и
пр
и
об
ра
щ
ен
ии
к
с
ер
ви
са
м
. С
ер
ви
сы
з
ад
ей
ст
ву
ю
т
эт
от
то
ке
н
дл
я
по
лу
че
ни
я
ин
ф
ор
м
ац
ии
о
с
уб
ъе
кт
е.
A
PI
-ш
лю
з
м
ож
ет
и
сп
ол
ьз
ов
ат
ь
то
ке
н
бе
зо
па
сн
ос
ти
в
к
ач
ес
тв
е
то
ке
на
с
еа
нс
а
414 Глава 11 • Разработка сервисов, готовых к промышленному использованию
Клиенты, которые входят в систему, проходят через такую цепочку событий.
1. Клиент делает запрос на вход в систему, содержащий учетные данные.
2. API-шлюз возвращает токен безопасности.
3. Клиент включает токен безопасности в запрос на выполнение операции.
4. API-шлюз проверяет токен безопасности и направляет запрос к сервису (-ам).
Чуть позже в этой главе я опишу, как реализовывать токены, но сначала рассмо
трим другой ключевой аспект безопасности — авторизацию.
Выполнение авторизации
Аутентификация учетных данных клиентов — важная задача, но этого недостаточно.
Приложение должно также реализовать механизм авторизации, который проверяет,
позволено ли клиенту выполнять запрошенную операцию. Например, в приложении
FTGO операцию getOrderDetails() может вызывать только клиент, разместивший
соответствующий заказ (пример безопасности уровня экземпляра), и работник
службы поддержки, который ему помогает.
Авторизацию можно реализовать в API-шлюзе. Таким образом мы можем, к при
меру, открыть доступ к точке GET /orders/{orderld) только тем пользователям, кото
рые являются клиентами или работниками службы поддержки. Если пользователю
не разрешено обращаться к определенному пути, API-шлюз может отклонить его
запрос до того, как он будет направлен к сервису. Как и в случае с аутентификацией,
централизованное размещение авторизации в рамках API-шлюза снижает риск воз
никновения уязвимостей. Для этого можно использовать фреймворк безопасности
наподобие Spring Security.
Одним из недостатков этого подхода является риск привязки API-шлюза к сер
висам, что потребует синхронизации их обновлений. Более того, API-шлюз обычно
способен реализовать только ролевой доступ к URL-адресам. Как правило, списки
ACL, которые управляют доступом к отдельным доменным объектам, реализуют
в другом месте, поскольку для этого требуется хорошее знание доменной логики
сервиса.
Авторизацию можно реализовать и внутри сервисов. Сервис может выполнять
ролевую авторизацию для URL-адресов и своих методов. Он также может поддер
живать списки ACL для управления доступом к агрегатам. Сервис Order, к примеру,
может реализовать механизм авторизации на основе ролей и ACL для контроля за
доступом к заказам. В этом случае другие сервисы приложения FTGO реализуют
аналогичную авторизационную логику.
Использование JWT для передачи ролей
и учетных данных пользователя
При обеспечении безопасности в микросервисной архитектуре вам нужно решить,
с помощью какого рода токена API-шлюз будет передавать сервисам пользователь
скую информацию. Существует два типа токенов, из которых вы можете выбрать.
11.1. Разработка безопасных сервисов 415
Один из вариантов — непрозрачные токены, которые обычно имеют формат UUID.
Их недостатком является понижение производительности и доступности, а также
увеличение латентности. Это связано с тем, что получатели таких токенов должны
делать синхронные RPC-вызовы к сервису безопасности, чтобы проверить их кор
ректность и извлечь информацию о пользователе.
Альтернативным подходом, который устраняет необходимость в обращении
к сервису безопасности, является применение прозрачных токенов, содержащих
пользовательские данные. В качестве одного из популярных стандартов для такого
рода токенов можно привести JWT (JSON Web token). JWT — это стандартный
способ безопасного представления между двумя сторонами такой информации, как
идентификаторы и роли пользователя. Токен JWT содержит так называемую полез
ную нагрузку (payload) в виде JSON-объекта со сведениями о пользователе, такими
как его идентификаторы и роли, а также другими метаданными, например сроком
годности. Он подписывается секретным ключом, известным только его создателю
(например, API-шлюзу) и получателю (например, сервису). Благодаря этому ключу
посторонние не могут подделать токен JWT.
Из-за автономности у токена JWT есть одна проблема: его нельзя отозвать.
После проверки подписи и срока годности сервис обязательно выполнит запро
шенную операцию. Таким образом, вы не можете отозвать отдельный токен, кото
рый попал в руки злоумышленнику. Чтобы с этим бороться, можно устанавливать
короткие сроки годности — это ограничит задумавшим недоброе пространство для
маневра. Недостаток этого подхода состоит в том, что приложение должно посто
янно переиздавать токены JWT, чтобы поддерживать сеанс в активном состоянии.
К счастью, эта и многие другие проблемы уже решены в стандарте безопасности
OAuth 2.0. Посмотрим, как это работает.
Использование OAuth 2.0 в микросервисной архитектуре
Представьте, что в приложении FTGO нужно реализовать сервис User, который
управляет базой данных с пользовательской информацией, такой как учетные
данные и роли. API-шлюз обращается к сервису User, чтобы аутентифицировать
клиентский запрос и получить JWT. Вы могли бы спроектировать API сервиса User
с помощью любимого веб-фреймворка. Но это общая функциональность, которая
не имеет прямого отношения к приложению FTGO, — создание такого сервиса
было бы неэффективной тратой времени разработчиков.
К счастью, вам не нужно разрабатывать такого рода инфраструктуру безопас
ности. Можно воспользоваться готовым сервисом или фреймворком, который
реализует стандарт OAuth 2.0. OAuth 2.0 — это протокол авторизации, который
изначально создавался для того, чтобы пользователи облачных сервисов, таких
как GitHub или Google, могли открывать доступ к своей информации сторонним
приложениям, не требуя ввода пароля. Например, с помощью OAuth 2.0 можно
дать доступ к своему репозиторию на GitHub стороннему облачному сервису не
прерывной интеграции.
Изначально идея OAuth 2.0 заключалась в авторизации доступа к публич
ным облачным приложениям, но вы можете задействовать эту технологию для
416 Глава 11 • Разработка сервисов, готовых к промышленному использованию
аутентификации и авторизации в своих проектах. Взглянем на то, как OAuth 2.0
можно интегрировать в микросервисную архитектуру.
Стандарт OAuth 2.0 основан на следующих концепциях.
□ Сервер авторизации — предоставляет API для аутентификации пользователей
и получения токенов доступа и обновления. Spring OAuth — хороший пример
фреймворка для построения сервера авторизации OAuth 2.0.
□ Токен доступа — токен, дающий доступ к серверу ресурсов. Его формат зависит
от реализации. Но некоторые фреймворки, например Spring OAuth, используют
для этого токены JWT.
□ Токен обновления — долгоживущий токен с возможностью отзыва, с помощью
которого клиент получает токен доступа.
□ Сервер ресурсов — задействует токен доступа для авторизации. В микросервисной
архитектуре серверами ресурсов выступают сами сервисы.
□ Клиент — хочет получить доступ к серверу ресурсов. В микросервисной архитек
туре роль клиента OAuth 2.0 играет API-шлюз.
Позже в этом разделе я покажу, как поддерживать клиенты, требующие входа
в систему. Но сначала поговорим о том, как аутентифицировать API-клиенты.
На рис. 11.4 показано, как API-шлюз выполняет аутентификацию запроса,
отправленного API-клиентом. Для этого он обращается к серверу авторизации
OAuth 2.0, который возвращает токен доступа. Затем API-шлюз использует этот
токен, чтобы сделать один или несколько запросов к сервисам.
Последовательность событий выглядит так.
1. Клиент делает запрос, предоставляя свои учетные данные в процессе НТТР-
аутентификации.
2. API-шлюз делает запрос типа OAuth 2.0 Password Grant (www.oauth.com/oauth2-
servers/access-tokens/password-grant/) к серверу аутентификации OAuth 2.0.
3. Сервер аутентификации проверяет учетные данные API-клиента и возвращает
токены доступа и обновления.
4. API-шлюз включает токен доступа в запросы, которые он отправляет сервисам.
Сервис проверяет токен доступа и использует его для авторизации запроса.
11.1. Разработка безопасных сервисов 417
и
Z
Q. до
ст
уп
а,
к
от
ор
ы
й
AP
I-
ш
лю
з
пе
ре
да
ет
с
ер
ви
са
м
. С
ер
ви
с
пр
ов
ер
яе
т
по
дп
ис
ь
то
ке
на
и
и
зв
ле
ка
ет
и
з
не
го
и
нф
ор
м
ац
ию
о
п
ол
ьз
ов
ат
ел
е,
вк
лю
ча
я
ег
о
уч
ет
ны
е
да
нн
ы
е
и
ро
ли
418 Глава 11 • Разработка сервисов, готовых к промышленному использованию
API-шлюз, основанный на OAuth 2.0, может задействовать токен доступа в ка
честве токена сеанса, чтобы аутентифицировать клиенты соответствующего типа.
Более того, когда заканчивается срок годности токена доступа, шлюз может получить
новый, используя токен обновления. На рис. 11.5 показано, как API-шлюз применя
ет OAuth 2.0 для работы с клиентами, ориентированными на сеансы. Чтобы иници
ировать сеанс, API-клиент передает свои учетные данные методом POST конечной
точке API-шлюза /login. Шлюз возвращает клиенту токены доступа и обновления,
а тот указывает их при обращении к шлюзу.
Последовательность событий выглядит так.
1. Клиент, требующий входа в систему, передает API-шлюзу свои учетные данные
методом POST.
2. Объект LoginHandler API-шлюза направляет запрос на выдачу пароля (www.oauth.com/
oauth2-servers/access-tokens/password-grant/) серверу аутентификации OAuth 2.0.
3. Сервер аутентификации проверяет учетные данные клиента и возвращает токе
ны доступа и обновления.
4. API-шлюз возвращает токены доступа и обновления клиенту, например, в виде
cookie.
5. Клиент включает токены доступа и обновления в запросы, которые делает к API-
шлюзу.
6. Перехватчик аутентификации сеанса API-шлюза проверяет токен доступа
и включает его в запросы, которые делает к сервисам.
Если токен доступа просрочен или истекает его срок годности, API-шлюз полу
чает новый токен, выполняя запрос типа OAuth 2.0 Refresh Grant (www.oauth.com/
oauth2-servers/access-tokens/refreshing-access-tokens/) к серверу авторизации и указывая
токен обновления. Если токен обновления не был просрочен или отозван, сервер
авторизации возвращает новый токен доступа, а API-шлюз передает его сервисам
и возвращает клиенту.
Важное преимущество OAuth 2.0 состоит в том, что это устоявшийся стандарт
безопасности. Используя готовый сервер аутентификации OAuth 2.0, вы можете
не тратить время на изобретение велосипеда, чреватое уязвимостями в архитектуре.
Однако OAuth 2.0 — это не единственный способ обеспечения безопасности в ми-
кросервисных приложениях. Вне зависимости от того, какой подход вы выберете,
следует помнить о трех ключевых принципах.
□ API-шлюз ответственен за аутентификацию клиентов.
□ API-шлюз и сервисы задействуют прозрачные токены, такие как JWT, для обмена
информацией о субъекте безопасности.
□ Сервис использует токен для получения учетных данных и ролей субъекта.
Вы узнали, как обезопасить свои сервисы. Теперь посмотрим, как сделать их
конфигурируемыми.
11.1. Разработка безопасных сервисов 419
420 Глава И • Разработка сервисов, готовых к промышленному использованию
11.2. Проектирование конфигурируемых
сервисов
Представьте, что вы отвечаете за сервис Order History. Он занимается перехватом
событий из Apache Kafka и чтением/записью элементов таблицы AWS DynamoDB
(рис. 11.6). Для работы этому сервису нужны различные конфигурационные свой
ства, такие как сетевое расположение Apache Kafka, а также адрес и учетные данные
AWS DynamoDB.
Рис. 11.6. Сервис Order History использует Apache Kafka и AWS DynamoDB. Ему нужно
предоставить конфигурацию с местоположением каждого компонента, учетными данными и т. д.
Значения этих конфигурационных свойств зависят от того, в какой среде выпол
няется сервис. Например, промышленная и отладочная среды используют разные
брокеры Apache Kafka и разные учетные данные для AWS. Нет никакого смысла
сохранять значения конфигурационных свойств прямо в коде развертываемого
сервиса, поскольку в этом случае его пришлось бы повторно собирать для каждой
отдельной среды. Вместо этого сервис следует собирать один раз и затем разверты
вать в разных средах.
Не стоит также сохранять в исходном коде наборы конфигурационных свойств
и в дальнейшем задействовать механизм профилей из фреймворка Spring для выбора
подходящего набора во время выполнения. Это создает брешь в системе безопасности
и ограничивает выбор сред для развертывания. Кроме того, конфиденциальная ин
формация, такая как учетные данные, должна безопасно храниться с использованием
механизма секретных ключей, такого как Hashicorp Vault (www.vaultproject.io) или AWS
Parameter Store (https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-
paramstore.html). Вы должны предоставить соответствующие конфигурационные свой
ства на этапе запуска сервиса с помощью конфигурации, вынесенной вовне.
11.2. Проектирование конфигурируемых сервисов 421
Механизм вынесения конфигурации вовне предоставляет экземпляру сервиса
значения свойств во время его выполнения. Есть два основных подхода.
□ Пассивная модель. Инфраструктура развертывания передает экземпляру сервиса
конфигурационные свойства, используя, к примеру, переменные системного
окружения или конфигурационный файл.
□ Активная модель. Экземпляр сервиса сам берет конфигурационные свойства
с сервера конфигурации.
Рассмотрим обе эти модели, начиная с пассивной.
11.2.1. Вынесение конфигурации вовне с помощью
пассивной модели
Пассивная модель полагается на совместную работу среды развертывания и сервиса.
Среда развертывания предоставляет конфигурационные свойства при создании эк
земпляра сервиса. Она может передать их в виде переменных окружения (рис. 11.7)
или поместить в конфигурационный файл. Затем экземпляр сервиса прочитает эти
свойства во время запуска.
Рис. 11.7. Когда инфраструктура развертывания создает экземпляр сервиса Order History, она
устанавливает переменные окружения с конфигурацией, вынесенной вовне. Сервис Order History
считывает эти переменные окружения
422 Глава 11 • Разработка сервисов, готовых к промышленному использованию
Среда развертывания и сервис должны согласовать способ предоставления
конфигурационных свойств. Выбор конкретного механизма зависит от среды раз
вертывания. Например, в главе 12 описывается, как переменные окружения можно
указать для контейнера Docker.
Допустим, вы решили предоставлять значения внешних конфигурационных
свойств в виде переменных окружения. Для получения этих значений можно исполь
зовать вызов System. getenv(). Но если вы Java-разработчик, у вас, скорее всего, уже
есть фреймворк с более удобным механизмом. Сервисы FTGO написаны с помощью
фреймворка Spring Boot, обладающего чрезвычайно гибким механизмом вынесения
конфигурации вовне. Он позволяет извлекать конфи1урационные свойства из целого
ряда источников с четкими приоритетами (https://docs.spring.io/spring-boot/docs/current/
reference/html/boot-features-external-config.html). Посмотрим, как это работает.
Spring Boot считывает свойства из целого ряда источников. Далее приведены
те из них, которые, с моей точки зрения, хорошо подходят для микросервисной
архитектуры.
1. Аргументы командной строки.
2. Переменная системного окружения SPRING_APPLICATI0N_3S0N или системное
свойство JVM с JSON внутри.
3. Системные свойства JVM.
4. Переменные системного окружения.
5. Конфигурационный файл в текущем каталоге.
Свойства, которые в этом списке находятся выше, переопределяют свойства,
следующие за ними. Например, переменные системного окружения переопределяют
свойства, прочитанные из конфигурационного файла.
Spring Boot делает эти свойства доступными для контекста Applicationcontext
в Spring Framework. Чтобы получить значение свойства, сервис может воспользо
ваться аннотацией @Value:
public class OrderHistoryDynamoDBConfiguration {
@Value("${aws.region}")
private String awsRegion;
Spring Framework инициализирует поле awsRegion с помощью значения свойства
aws. region. Это считывается из одного из приведенных ранее источников, например
из конфигурационного файла или переменной окружения AWS_REGION.
Пассивная модель — эффективный и широко распространенный механизм
конфигурации сервисов. Одно из ее ограничений состоит в том, что изменение
конфигурации уже запущенного сервиса способно оказаться непростой или даже
невыполнимой задачей. Инфраструктура развертывания может не позволить вам
изменить внешние свойства сервиса без его перезапуска. Например, нельзя изменить
переменные окружения запущенного процесса. Еще одно ограничение связано с тем,
что значения конфигурационных свойств могут быть разбросаны по определениям
многочисленных сервисов. В связи с этим стоит подумать об использовании актив
ной модели. Посмотрим, как она работает.
11.2. Проектирование конфигурируемых сервисов 423
11.2.2. Вынесение конфигурации вовне
с помощью активной модели
В активной модели экземпляр сервиса считывает конфигурационные свойства
с конфигурационного сервера. На рис. 11.8 показано, как это выглядит. При запуске
экземпляр сервиса обращается к конфигурационному сервису за своей конфигура
цией. Конфигурационные свойства для доступа к сервису конфигурации (например,
его сетевое размещение) предоставляются через пассивный механизм, такой как
переменные окружения.
Рис. 11.8. При запуске экземпляр сервиса извлекает свои конфигурационные свойства из сервера
конфигурации. Конфигурационные свойства для доступа к серверу конфигурации предоставляются
инфраструктурой развертывания
Существует целый ряд способов реализации конфигурационного сервера, вклю
чая следующие:
□ систему управления версиями, такую как Git;
□ базы данных (SQL или NoSQL);
□ специализированные серверы конфигурации, такие как Spring Cloud Config
Server, Hashicorp Vault (для хранения конфиденциальной информации наподо
бие учетных данных) или AWS Parameter Store.
Хорошим примером фреймворка для работы с конфигурацией на основе сервера
является Spring Cloud Config. Он состоит из сервера и клиента. Сервер поддерживает
разнообразные хранилища для размещения конфигурационных свойств, включая
системы управления версиями, базы данных и Hashicorp Vault. Клиент извлекает
конфигурационные свойства из сервера и внедряет их в контекст Applicationcontext
Spring-приложения.
Использование сервера конфигурации дает несколько преимуществ.
□ Централизованная конфигурация. Все конфигурационные свойства хранятся в од
ном месте, благодаря чему ими легче управлять. Кроме того, чтобы не допустить
дублирования свойств, некоторые реализации позволяют определять значения по
умолчанию, которые переопределяются на уровне отдельных сервисов.
424 Глава 11 • Разработка сервисов, готовых к промышленному использованию
□ Прозрачная расшифровка конфиденциальных данных. Информацию особого рода,
такую как учетные данные для доступа к БД, рекомендуется шифровать. Но при
этом может возникнуть проблема: экземпляру сервиса обычно приходится ее
расшифровывать. Это означает, что ему нужны ключи шифрования. Некоторые
серверы конфигурации автоматически расшифровывают свойства перед тем, как
вернуть их сервису.
□ Динамическое изменение конфигурации. Сервис потенциально может отслеживать
обновления своих свойств, например периодически проверяя их, и изменять свою
конфигурацию.
Основной недостаток использования сервера конфигурации — то, что это еще один
инфраструктурный компонент, который нужно настраивать и обслуживать (разве
что он предоставляется самой инфраструктурой). К счастью, открытые фреймворки,
такие как Spring Cloud Config, упрощают работу с конфигурационным сервером.
Итак, вы узнали, как проектировать конфигурируемые сервисы. Теперь погово
рим о том, как сделать их наблюдаемыми.
11.3. Проектирование наблюдаемых сервисов
Представьте, что вы развернули приложение FTGO в промышленной среде.
Вам, скорее всего, будет интересно узнать такие его показатели, как количество за
просов в секунду, степень использования ресурсов и т. д. Если возникнет проблема,
например отказ экземпляра сервиса или переполнение диска, вы должны о ней узнать,
и желательно до того, как она затронет пользователей. К тому же у вас должна быть
возможность диагностировать эту проблему и определить ее первопричину.
Многие аспекты управления приложением в промышленных условиях, такие как
мониторинг доступности и возможности применения аппаратных ресурсов, нахо
дятся вне зоны ответственности разработчиков. Эта обязанность явно принадлежит
системным администраторам. Но вы, как разработчик, обязаны использовать опре
деленные шаблоны проектирования, чтобы сделать свои сервисы более простыми для
управления и диагностики. Эти шаблоны (рис. 11.9) предоставляют информацию
о поведении и работоспособности сервиса. Они позволяют системе мониторинга
отслеживать и визуализировать его состояние и генерировать оповещения при воз
никновении проблемы. К тому же шаблоны упрощают процесс диагностики.
Проектировать наблюдаемые сервисы вы можете с помощью следующих ша
блонов.
□ API проверки работоспособности — предоставляет конечную точку, которая воз
вращает данные о работоспособности сервиса.
□ Агрегация журналов — ведет журналы активности сервисов и сохраняет их на
центральном журнальном сервере с поддержкой поиска и оповещений.
□ Распределенная трассировка — назначает каждому внешнему запросу уникаль
ный идентификатор и отслеживает запросы по мере их перемещения между
сервисами.
11.3. Проектирование наблюдаемых сервисов 425
ин
ф
ор
м
ац
ии
, к
от
ор
ую
п
ре
до
ст
ав
ля
ю
т
се
рв
ис
ы
426 Глава И • Разработка сервисов, готовых к промышленному использованию
□ Отслеживание исключений — за исключениями следит отдельный сервис, кото
рый избавляется от дубликатов, оповещает разработчиков и отслеживает обра
ботку каждого исключения.
□ Метрики приложения — сервисы собирают метрики, такие как счетчики и оце
ночные показатели, и делают их доступными серверу метрик.
□ Ведение журнала аудита — ведет журнал действий пользователей.
Отличительной чертой большинства этих шаблонов является то, что они состоят
из двух компонентов: для разработчиков и для администраторов. Возьмем, к при
меру, шаблон API проверки работоспособности. Разработчик ответственен за реали
зацию конечной точки с данными о работоспособности. Системный администратор
отвечает за систему мониторинга, которая периодически обращается к этому API.
Аналогично работает шаблон агрегации журналов: разработчик делает так, чтобы
его сервисы вели журналы с полезной информацией, а администратор занимается
агрегированием этих журналов.
Рассмотрим каждый из этих шаблонов, начиная с API проверки работоспособ
ности.
11.3.1. Использование API проверки работоспособности
Иногда запущенный сервис не в состоянии обрабатывать запросы. Например, экзем
пляр сервиса может быть не готов принимать запросы сразу после запуска. Скажем,
для инициализации адаптеров обмена сообщениями и базы данных сервису Consumer
требуется около 10 с. Поэтому инфраструктуре развертывания не следует направ
лять ему HTTP-запросы, пока он не будет в состоянии их обрабатывать.
Кроме того, экземпляр сервиса может дать сбой, не завершая при этом работу.
Например, из-за ошибки сервис Consumer может исчерпать доступные соединения
с базой данных, в результате чего он будет не способен к ней обращаться. Инфра
структура развертывания не должна направлять запросы экземплярам сервисов,
которые дали сбой, но все еще работают. И если такой сервис не восстанавливается,
инфраструктура должна его удалить и создать вместо него новый экземпляр.
Экземпляр сервиса должен иметь возможность сообщить инфраструктуре
развертывания о том, способен ли он обрабатывать запросы. Хорошим решением
будет реализация в сервисе конечной точки для проверки работоспособности
(рис. 11.10). Например, Java-библиотека Spring Boot Actuator предоставляет ко
нечную точку GET /actuator/health, которая возвращает коды 200 или 503 в зави-
11.3. Проектирование наблюдаемых сервисов 427
симости от состояния сервиса. Для .NET есть аналогичная библиотека Healthchecks
(https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/implement-resilient-
applications/monitor-app-health), реализующая конечную точку GET /he. Инфраструкту
ра развертывания периодически обращается по соответствующему адресу, чтобы
понять, в каком состоянии находится экземпляр сервиса, и принять необходимые
меры, если тот оказался неработоспособным.
Рис. 11.10. Сервис реализует конечную точку для проверки работоспособности, к которой
периодически обращается инфраструктура развертывания, чтобы определить состояние
экземпляра сервиса
Обработчик запросов проверки работоспособности обычно тестирует соединения
экземпляра сервиса с внешними компонентами. Он может, к примеру, выполнить
тестовый запрос к базе данных. Если все тесты прошли успешно, он возвращает
соответствующий ответ, такой как HTTP-код состояния 200. Если любой из них
завершится неудачно, ответ будет сигнализировать о плохой работоспособности
(например, в виде HTTP-кода состояния 500).
Обработчик запросов проверки работоспособности может просто вернуть пу
стой HTTP-ответ с подходящим кодом или подробно описать работоспособность
каждого адаптера. Детальная информация может пригодиться для диагностики.
Но поскольку некоторые ее аспекты могут оказаться конфиденциальными, такие
фреймворки, как Spring Boot Actuator, позволяют настраивать уровень детализации
ответов конечной точки.
При использовании проверок работоспособности следует обратить внимание
на два момента. Во-первых, это реализация конечной точки, которая будет отчиты
ваться о состоянии экземпляра сервиса. Во-вторых, инфраструктура развертывания
должна быть сконфигурирована для обращения к этой конечной точке. Начнем
с реализации.
428 Глава И • Разработка сервисов, готовых к промышленному использованию
Реализация конечной точки для проверки работоспособности
Код, реализующий конечную точку для проверки работоспособности, должен каким-
то образом определять состояние экземпляра сервиса. Один из простых подходов
заключается в том, чтобы проверять, может ли тот обращаться к внешним компо
нентам. То, как это сделать, зависит от отдельных инфраструктурных сервисов.
Например, код проверки работоспособности может получить соединение к СУРБД
и выполнить тестовый запрос. Более сложным было бы выполнение синтетической
транзакции, которая симулирует вызов API сервиса со стороны клиента. Подобные
проверки получаются более глубокими, но обычно требуют больше времени на раз
работку и выполнение.
Отличный пример библиотеки для проверки работоспособности — Spring Boot
Actuator. Как упоминалось ранее, она предоставляет конечную точку /actuator/
health. Код, реализующий этот вызов, возвращает результат выполнения целого
набора проверок. Применяя соглашение о конфигурации, Spring Boot Actuator вы
полняет подходящий набор проверок с учетом того, какие инфраструктурные ком
поненты использует сервис. Например, если сервис работает с объектом Datasource
из JDBC, Spring Boot Actuator конфигурирует проверку для выполнения тестового
запроса. Точно так же, если сервис задействует брокер сообщений RabbitMQ, он
автоматически подготавливает проверку доступности сервера RabbitMQ.
Это поведение можно изменять, выполняя дополнительные проверки работо
способности вашего сервиса. Для этого нужно определить класс, который реализует
интерфейс Healthindicator. У этого интерфейса есть метод health(), вызываемый
реализацией конечной точки /actuator/health. Он возвращает результат проверки
работоспособности.
Обращение к конечной точке проверки работоспособности
Конечная точка проверки работоспособности окажется практически бесполезной, если
ее некому будет вызывать. При развертывании своего сервиса вы должны сконфигу
рировать инфраструктуру так, чтобы она обращалась к его конечной точке. То, как
это сделать, зависит от специфики вашей инфраструктуры развертывания. Например,
как говорилось в главе 3, вы можете подготовить реестр сервисов наподобие Netflix
Eureka, чтобы тот обращался к конечным точкам для проверки работоспособности
и решал, стоит ли направлять трафик к экземпляру сервиса. В главе 12 вы узнаете,
как сконфигурировать Docker и Kubernetes для вызова таких конечных точек.
11.3.2. Применение шаблона агрегации журналов
Журналы незаменимы при диагностике. Если вы хотите узнать, что не так с вашим
приложением, лучше всего начать с журнальных файлов. Однако ведение журналов
в микросервисной архитектуре сопряжено с определенными трудностями. Пред
ставьте, например, что вы отлаживаете проблемный запрос getOrderDetails().
Как говорилось в главе 8, приложение FTGO реализует его, объединяя API-
интерфейсы. В итоге нужные вам журнальные записи оказываются разбросанными
между API-шлюзом и несколькими сервисами, включая Order и Kitchen.
11.3. Проектирование наблюдаемых сервисов 429
Решение состоит в использовании агрегации журналов. Этот процесс отправляет
журналы всех сервисов на центральный журнальный сервер (рис. 11.11). Как только
сервер их сохранит, вы сможете их просматривать, искать и анализировать, а также
сконфигурировать оповещения, которые срабатывают при появлении в журналах
определенного сообщения.
Рис. 11.11. Инфраструктура агрегации журналов шлет журналы со всех экземпляров
всех сервисов на центральный журнальный сервер. Пользователи могут просматривать
журналы и искать по ним. Они также могут настраивать оповещения, которые срабатывают,
когда журнальная запись соответствует поисковым критериям
За процесс агрегации и журнальный сервис обычно отвечают системные админи
страторы. А вот написание сервисов, генерирующих осмысленные журналы, ложится
на разработчиков. Сначала посмотрим, как сервис генерирует журнал.
Как сервис генерирует журнал
Вы, как разработчик сервиса, должны позаботиться о нескольких важных моментах.
Во-первых, вам нужно выбрать библиотеку ведения журнала. Во-вторых, вы должны
решить, куда вносить журнальные записи. Начнем с библиотеки ведения журнала.
430 Глава 11 • Разработка сервисов, готовых к промышленному использованию
В большинстве языков программирования есть одна или несколько библиотек для
ведения журналов, которые облегчают генерацию правильно структурированных за
писей. Например, в Java для этого есть три популярных решения: Logback, log4j и JUL
(java. util. logging). Можно также вспомнить SLF4J — API для различных журнальных
фреймворков. В NodeJS доступен аналогичный фреймворк Log4JS. Один из разумных
способов ведения журнала состоит в интеграции вызовов к этим библиотекам в код
ваших сервисов. Но если существуют строгие требования к ведению журнала, которые
нельзя удовлетворить с помощью сторонних библиотек, вам, возможно, придется на
писать собственный API, который будет служить оберткой для одной из них.
Также нужно определиться с тем, куда записывать журнал. Традиционно для
этого используется журнальный файл, размещенный в известном всем каталоге.
Но в случае работы с современными технологиями развертывания, такими как кон
тейнеры и бессерверные платформы (см. главу 12), это будет не лучшим решением.
В некоторых средах, таких как AWS Lambda, нет даже такого понятия, как посто
янная файловая система, где можно хранить журнальные записи! Вместо этого ваш
сервис должен записывать журнал в stdout, а инфраструктура развертывания будет
решать, что делать с выводом сервиса.
Инфраструктура агрегации журналов
Инфраструктура ведения журналов отвечает за их агрегацию, хранение и предо
ставление пользователям функции поиска. Популярным решением в этой области
является стек ELK. Он состоит из трех продуктов с открытым исходным кодом:
□ Elasticsearch — база данных типа NoSQL, ориентированная на текстовый поиск.
Используется в качестве журнального сервера;
□ Logstash — конвейер, который агрегирует журналы сервисов и записывает их
в Elasticsearch;
□ Kibana — инструмент визуализации для Elasticsearch.
В качестве примеров других открытых решений для работы с журналами можно
привести Fluentd и Apache Flume. В число журнальных сервисов можно включить
как облачные сервисы, такие как AWS CloudWatch Logs, так и многочисленные
коммерческие продукты. Агрегация журналов служит полезным инструментом от
ладки в микросервисной архитектуре.
Теперь поговорим о распределенной трассировке — еще одном подходе к пони
манию поведения приложений, основанных на микросервисах.
11.3.3. Использование шаблона распределенной
трассировки
Представьте, что вы разработчик системы FTGO и пытаетесь понять причину замед
ления запроса getOrderDetails(). Вы уже определили, что это не внешние сетевые
проблемы. Виновником возросшей латентности должен быть либо API-шлюз, либо
один из сервисов, к которому он обращается. Один из вариантов заключается в из-
11.3. Проектирование наблюдаемых сервисов 431
мерении среднего времени ответа каждого сервиса. Но проблема в том, что это не по
зволяет исследовать выполнение отдельно взятых запросов. К тому же в сложных
сценариях вам, вероятно, придется иметь дело с множеством вложенных обращений
к сервисам, часть из которых могут оказаться незнакомыми. Это затрудняет диа
гностику подобного рода проблем с производительностью и их устранение в микро
сервисной архитектуре.
Распределенная трассировка — это хороший способ разобраться в том, чем за
нимается ваше приложение. Она является аналогом профайлеров производитель
ности в монолитных системах. Она записывает информацию (например, начальное
и конечное время), относящуюся к иерархии обращений к сервисам, выполняемых
во время обработки запроса. Это позволяет понять, как сервисы взаимодействуют
между собой при поступлении внешних запросов и на что именно затрачивается
время.
Пример того, как сервер распределенной трассировки визуализирует обработку
запроса API-шлюзом, показан на рис. 11.12. Вы видите входящий запрос к шлюзу,
а также запрос, который шлюз делает к сервису Order. В обоих случаях сервер рас
пределенной трассировки показывает выполняемую операцию и временные рамки
запроса.
Рис. 11.12. Сервер Zipkin показывает, как приложение FTGO обрабатывает запрос, который
API-сервер направляет к сервису Order. Каждый запрос представлен в виде следа. След — это
набор интервалов. Каждый интервал описывает вызов сервиса и может содержать дочерние
интервалы. В зависимости от детализации собранных данных интервал может также относиться
к вызову операции внутри сервиса
432 Глава 11 • Разработка сервисов, готовых к промышленному использованию
На рис. 11.12 показано то, что в терминологии распределенной трассировки
называется следом. След описывает внешний запрос и состоит из одного или
нескольких интервалов. Интервал представляет собой операцию с такими клю
чевыми атрибутами, как название, начальное и конечное время. Интервал может
содержать дочерние интервалы, которые представляют вложенные операции.
Например, интервал верхнего уровня может описывать обращение к API-шлюзу
(см. рис. 11.12). Его дочерние интервалы относятся к вызовам, которые API-шлюз
направляет сервисам.
Полезным побочным эффектом распределенной трассировки является назна
чение уникального идентификатора каждому внешнему запросу. Сервис может
включать эти идентификаторы в свои журнальные записи. В сочетании с агрегацией
журналов это позволяет легко находить записи для отдельных внешних вызовов.
Далее показан пример журнальной записи из сервиса Order:
2018-03-04 17:38:12.032 DEBUG [ftgo-orderservice,
8d8fdc37bel04cc6,8d8fdc37bel04cc6,false]
7 — [nio-8080-exec-6] org.hibernate.SQL :
select order0_.id as idl_3_0_, order0_.consumer_id as consumer2_3_0_, order
0_.city as city3_3_0_?
order0_.delivery_state as delivery4_3_0_, order0_.streetl as street5_3_0_,
order0_.street2 as street6_3_0_, order0_.zip as zip7_3_0_,
order0_.delivery_time as delivery8_3_0_, order0_.a
Фрагмент журнальной записи [ftgo-order-service,8d8fdc37bel04cc6,8d8fdc37
bel04cc6, false] (сопоставляемый контекст диагностики в SLF4J — см. www.slf4j.org/
manual.html) содержит информацию, предоставленную инфраструктурой распреде
ленной трассировки. Он состоит из четырех значений:
□ ftgo-order-service — название приложения;
□ 8d8fdc37bel04cc6 — поле traceld;
□ 8d8fdc37bel04cc6 — поле spanld;
□ false — говорит о том, что этот интервал не был экспортирован в сервер рас
пределенной трассировки.
Если поискать в журналах 8d8fdc37bel04cc6, можно найти все записи, относя
щиеся к этому запросу.
На рис. 11.13 показано, как работает распределенная трассировка. Она состоит из
двух частей: библиотеки инструментирования, которую использует каждый сервис,
и сервера распределенной трассировки. Библиотека инструментирования управляет
следами и интервалами. Она также включает в исходящие запросы трассировоч
ную информацию, такую как идентификаторы текущего и родительского следов.
Например, один из распространенных стандартов для передачи трассировочной
информации, ВЗ (github.com/openzipkin/b3-propagation), применяет заголовки наподобие
Х-ВЗ-TraceId и Х-ВЗ-ParentSpanId. Кроме того, библиотека инструментирования
передает следы серверу распределенной трассировки, который хранит их и предо
ставляет пользовательский интерфейс для их визуализации.
11.3. Проектирование наблюдаемых сервисов 433
Рис. 11.13. Библиотека инструментирования применяется во всех сервисах, включая API-шлюз.
Она назначает ID для каждого внешнего запроса, распространяет состояние трассировки между
сервисами и передает следы серверу распределенной трассировки
Рассмотрим по очереди библиотеку инструментирования и сервер распределен
ной трассировки.
Использование библиотеки инструментирования
Библиотека инструментирования создает иерархию следов и передает ее серверу
распределенной трассировки. Она может вызываться напрямую в коде сервиса,
но это будет вмешательством в бизнес-логику и другую функциональность. Более
элегантный подход — применение перехватчиков или аспектно-ориентированного
программирования (АОП).
Отличным примером фреймворка, основанного на АОП, служит Spring Cloud
Sleuth. С помощью механизма АОГ1 из состава Spring он автоматически интегрирует
434 Глава 11 • Разработка сервисов, готовых к промышленному использованию
распределенную трассировку в сервисы. Поэтому вам следует добавить Spring Cloud
Sleuth в качестве одной из зависимостей проекта. Вашему сервису нужно вызывать
API распределенной трассировки только в тех случаях, которые не охвачены данным
фреймворком.
О сервере распределенной трассировки
Библиотека инструментирования шлет следы на сервер распределенной трассиров
ки. Тот собирает их в единую иерархию и сохраняет в базу данных. Одним из по
пулярных серверов распределенной трассировки является Open Zipkin, изначально
разработанный компанией Twitter. Для доставки следов в Zipkin сервисы могут
использовать либо HTTP, либо брокер сообщений. Zipkin помещает следы в храни
лище, роль которого может играть база данных типа SQL или NoSQL. У него есть
пользовательский интерфейс, который вы видели ранее на рис. 11.12. Еще одним
сервером распределенной трассировки можно считать AWS Х-гау.
11.3.4. Применение шаблона «Показатели приложения»
Ключевую роль в промышленной среде играют мониторинг и оповещения. Систе
ма мониторинга собирает показатели всех компонентов стека технологий, чтобы
получить критически важную информацию о работоспособности приложения
(рис. 11.14). Показатели могут быть инфраструктурными (например, нагрузка на
процессор, память и диск) и программными (например, латентность обращений
к сервисам и количество выполненных запросов). Так, сервис Order собирает
информацию о количестве размещенных, принятых и отклоненных заказов.
Показатели агрегирует отдельный сервис, который предоставляет визуализацию
и оповещения.
Показатели снимаются периодически. Каждый образец содержит три свойства:
□ название — название показателя, такое как jvm_memory_max_bytes или placed_
orders;
□ значение — числовое значение;
□ временную метку — время снятия показателя.
Кроме того, некоторые системы мониторинга поддерживают концепцию из
мерений, которые представляют собой произвольные пары «имя — значение».
11.3. Проектирование наблюдаемых сервисов 435
Например, показатель jvm_memory_max_bytes предоставляется с такими измерения
ми, как area="heap”,id=”PS Eden Space” и area=”heap”,id=”PS Old Gen”. Измерения
часто несут в себе дополнительную информацию: имя компьютера или сервиса,
идентификатор экземпляра сервиса и т. д. Система мониторинга обычно агрегирует
(суммирует или вычисляет среднее значение) выборки по одному или нескольким
измерениям.
Рис. 11.14. Показатели собирают на каждом уровне стека и хранят в отдельном сервисе, который
предоставляет визуализацию и оповещения
За многие аспекты мониторинга отвечают системные администраторы. Но и у раз
работчиков сервисов есть две обязанности, связанные с показателями. Во-первых,
они должны сделать так, чтобы их сервисы собирали сведения о своем поведении.
Во-вторых, им нужно открыть эти сведения вместе с информацией из JVM и фрейм
ворка приложения для сервера показателей.
Посмотрим, как собирают показатели уровня сервиса.
Сбор показателей уровня сервиса
Количество усилий, которые нужно приложить для сбора показателей, зависит от
фреймворка вашего приложения и того, какая информация вас интересует. Напри
мер, сервис, основанный на Spring Boot, может собирать и делать доступными извне
базовые показатели, относящиеся к JVM. Для этого нужно указать в списке зависи
мостей библиотеку Micrometer Metrics и написать несколько строчек в конфигура
ционном файле. Механизм автоконфигурации Spring Boot берет на себя настройку
436 Глава И • Разработка сервисов, готовых к промышленному использованию
библиотеки Micrometer Metrics и открытие доступа к собранным показателям.
Использование API библиотеки Micrometer Metrics напрямую имеет смысл только
в случае, если сервис собирает сведения о приложении.
В листинге 11.1 показано, как сервис Order собирает показатели количества раз
мещенных, принятых и отклоненных заказов. Для сбора нестандартных показателей
он задействует интерфейс MeterRegistry из состава Micrometer Metrics. Каждый
метод инкрементирует счетчик с соответствующим именем.
Листинг 11.1. Сервис Order отслеживает количество размещенных, принятых
и отклоненных заказов
public class OrderService {
API библиотеки Micrometer Metric для работы
@Autowi red с показателями уровня приложения
private MeterRegistry meterRegistry; ◄--------
public Order createOrder(...) {
meterRegistry.counter("placed_orders’'). increment(); <
return order;
}
public void approveOrder(long orderld) {
meterRegistry.counter("approved_orders").increment();
}
Инкрементирует
счетчик placedOrders
при успешном
размещении заказа
Инкрементирует
счетчик approvedOrders,
когда заказ принимается
public void rejectOrder(long orderld) {
meterRegistry.counter("rejected_orders").increment(); <
}
Инкрементирует
счетчик rejectedOrders,
когда заказ отклоняется
Доставка собранных данных на сервер показателей
Собранные сведения могут попасть от сервиса к серверу показателей одним из двух
способов — пассивным или активным. В пассивной модели экземпляр сервиса сам
шлет показатели серверу, вызывая его API. Эта модель реализована, к примеру,
в AWS Cloudwatch.
В активной модели сервер показателей или его агент, запущенный локально,
обращается к API сервиса, чтобы извлечь собранную им информацию. Этот подход
применяется в Prometheus — популярной системе для мониторинга и рассылки
оповещений с открытым исходным кодом.
Для интеграции с Prometheus сервис Order приложения FTGO использует библио
теку micrometer-registry-prometheus. Поскольку эта библиотека указана в списке
путей classpath, Spring Boot предоставляет конечную точку GET /actuator/prometheus,
которая возвращает показатели в формате, совместимом с Prometheus. Отчет о поль
зовательских показателях, собранных сервисом Order, выглядит так:
11.3. Проектирование наблюдаемых сервисов 437
$ curl -v http://localhost:8080/actuator/prometheus | grep _orders
# HELP placed_orders_total
# TYPE placed_orders_total counter
placed_orders_total{service="ftgo-order-service",} 1.0
# HELP approved_orders_total
# TYPE approved_orders_total counter
approved_orders_total(service=”ftgo-order-service"J} 1.0
Например, счетчик placed_orders передается в качестве показателя типа counter.
Сервер Prometheus периодически обращается к этой конечной точке для извле
чения показателей. Оказавшись на сервере, показатели становятся доступными для
просмотра с помощью Grafana — инструмента для визуализации данных (grafana.com).
Вы также можете настроить оповещения для этих показателей, например, на слу
чай, если скорость изменения placed_orders_total станет ниже определенного
значения.
Показатели приложения предоставляют ценную информацию о его поведении.
Оповещения, которые срабатывают в зависимости от их изменения, позволяют
быстро реагировать на проблемы в промышленной среде — возможно, даже до того,
как они затронут пользователей. Давайте посмотрим, как отслеживать еще один вид
оповещений — исключения — и реагировать на них.
11.3.5. Шаблон отслеживания исключений
Исключения редко попадают в журнал сервиса, но когда это происходит, очень
важно определить их первопричину. Исключение может быть симптомом отказа или
ошибки в коде. Традиционно исключения просматриваются в журнале. Вы даже мо
жете настроить журнальный сервер таким образом, чтобы он оповещал вас о любом
записанном исключении. Однако у этого подхода есть несколько проблем.
□ Журнальные файлы рассчитаны на однострочные записи, тогда как исключения
состоят из множества строчек.
□ Нет механизма для отслеживания того, как решаются проблемы с записанными
исключениями. Вам придется вручную копировать и вставлять исключение
в систему отслеживания проблем.
□ Исключения, скорее всего, будут дублироваться, но механизма для их автомати
ческой группировки не существует.
438 Глава 11 • Разработка сервисов, готовых к промышленному использованию
Более подходящей альтернативой является использование сервиса для от
слеживания исключений. Ваш код сообщает этому сервису об исключениях через
API — например, REST (рис. 11.15). Тот их дедуплицирует, управляет процессом
их исправления и генерирует оповещения.
Рис. 11.15. Ваш код сообщает об исключениях специальному сервису, который их дедуплицирует
и оповещает разработчиков. У него есть пользовательский интерфейс для просмотра исключений
и управления ими
Сервис отслеживания исключений можно интегрировать в приложение несколь
кими способами. Код может вызывать его API напрямую. Но лучше использовать
для этого клиентскую библиотеку, предоставляемую этим сервисом. Например,
клиентская библиотека HoneyBadger поддерживает несколько простых механизмов
интеграции, включая Servlet Filter, который перехватывает исключения и сообщает
о них.
11.3. Проектирование наблюдаемых сервисов 439
Шаблон отслеживания исключений помогает быстро идентифицировать про
блемы в промышленной среде и реагировать на них.
Отслеживать поведение пользователей тоже очень важно. Посмотрим, как это
делается.
11.3.6. Применение шаблона «Ведение журнала аудита»
Цель ведения журнала аудита — запись всех действий пользователя. Журнал аудита
обычно используется для помощи службе поддержки, соблюдения нормативно
правовых норм и обнаружения подозрительной активности. Каждая запись в таком
журнале содержит идентификатор пользователя, выполненное им действие и бизнес-
объект (-ы). Приложения обычно хранят журналы аудита в базах данных.
Ведение журнала аудита можно реализовать несколькими разными способами.
□ Добавить код ведения журнала аудита в бизнес-логику.
□ Задействовать аспектно-ориентированное программирование.
□ Использовать порождение событий.
Рассмотрим каждый из этих вариантов.
Добавление кода для ведения журнала аудита в бизнес-логику
Первый и самый простой вариант — внедрить код ведения журнала аудита в бизнес-
логику вашего сервиса. Например, каждый метод сервиса может создавать новую
запись и сохранять ее в базе данных. Недостаток этого подхода состоит в том, что
он связывает код ведения журнала аудита и бизнес-логику, что осложняет обслужи
вание приложения. Еще один отрицательный аспект связан с повышенным риском
возникновения ошибок, так как за написание кода для ведения журнала аудита от
вечает разработчик.
Использование аспектно-ориентированного программирования
Второй вариант заключается в применении АОП. Вы можете воспользоваться
АОП-фреймворком, таким как Spring АОР, для создания так называемых советов,
которые автоматически перехватывают вызов каждого метода внутри сервиса и со
храняют запись в журнал аудита. Это куда более надежный подход, поскольку он
440 Глава И • Разработка сервисов, готовых к промышленному использованию
автоматически записывает все вызовы. Его основным недостатком является то, что
совет имеет доступ только к названию метода и его аргументам, поэтому у вас мо
гут возникнуть проблемы с определением бизнес-объекта, на который направлено
действие, и генерацией бизнес-ориентированной журнальной записи.
Использование порождения событий
Третьим, последним вариантом является реализация бизнес-логики с применением
порождения событий. Как упоминалось в главе 6, порождение событий автоматиче
ски предоставляет журнал аудита для операций создания и обновления. При этом
вам нужно записывать учетные данные пользователя в каждое событие. Одно из
ограничений этого подхода связано с тем, что он не записывает запросы. Если ваш
сервис должен создавать журнальные записи для запросов, следует выбрать другой
вариант.
11.4. Разработка сервисов с помощью
шаблона микросервисного шасси
В этой главе описано множество функций, которые должен поддерживать ваш
сервис, включая показатели, отправку отчетов об исключениях, ведение журнала,
проверку работоспособности, работу с внешней конфигурацией и безопасность.
Кроме того, как вы уже знаете из главы 3, сервис отвечает также за свое обнаружение
и реализацию предохранителей. Вряд ли вам захочется подготавливать все это с нуля
при создании каждого нового сервиса, ведь это может занять дни, а то и недели.
И в это время вы не сможете написать ни единой строчки бизнес-логики.
Чтобы значительно ускорить этот процесс, сервисы можно разрабатывать поверх
микросервисных шасси. Шасси микросервисов (рис. 11.16) — это фреймворк или набор
фреймворков, которые берут на себя перечисленные обязанности. Для реализации
всех этих функций от вас не требуется почти (или даже совсем) никакого кода.
Я начну раздел с описания концепции микросервисного шасси и предложу не
сколько замечательных фреймворков, которые ее воплощают. Вы также познако
митесь с понятием «сеть сервисов», которое на момент написания книги является
новой интригующей альтернативой фреймворкам и библиотекам.
Сначала обсудим шасси микросервисов.
11.4. Разработка сервисов с помощью шаблона микросервисного шасси 441
Рис. 11.16. Шасси микросервисов — это фреймворк, который берет на себя многочисленные
обязанности, такие как отслеживание исключений, ведение журнала, проверки работоспособности,
работа с внешней конфигурацией и распределенная трассировка
11.4.1. Использование шасси микросервисов
Шасси микросервисов — это фреймворк или набор фреймворков, которые берут на
себя многочисленные обязанности, включая следующие:
□ конфигурацию, вынесенную вовне;
□ проверку работоспособности;
□ показатели приложения;
□ обнаружение сервисов;
□ предохранители;
□ распределенную трассировку.
Это может значительно уменьшить объем кода, который вам необходимо напи
сать, или даже свести его к нулю. Вам нужно лишь адаптировать микросервисное
442 Глава 11 • Разработка сервисов, готовых к промышленному использованию
шасси под свои требования. Это позволяет сосредоточиться на разработке бизнес-
логики сервисов.
В качестве микросервисного шасси приложение FTGO использует фрейм
ворки Spring Boot и Spring Cloud. Spring Boot предоставляет такие функции, как
поддержка внешней конфигурации. Spring Cloud реализует шаблоны наподобие
предохранителя и обнаружения сервисов на стороне клиента (хотя у FTGO есть своя
инфраструктура для обнаружения сервисов). Помимо Spring Boot и Spring Cloud,
есть и другие фреймворки микросервисных шасси. Например, если вы пишете сер
висы на языке Go Lang, вам доступны проекты Go Kit (github.com/go-kit/kit) и Micro
(github.com/micro/micro).
У этого шаблона есть один недостаток: вам нужно будет подбирать отдельное
шасси для каждой комбинации языка/платформы, которую вы используете в раз
работке сервисов. К счастью, многие функции микросервисного шасси, скорее всего,
уже реализованы на уровне инфраструктуры. Например, как описывалось в главе 3,
многие среды развертывания занимаются обнаружением сервисов. Более того,
многие сетевые возможности микросервисных шасси уже реализованы в виде сети
сервисов (внешнего инфраструктурного слоя).
11.4.2. От микросервисного шасси до сети сервисов
Шасси микросервисов ~ хороший способ реализации универсальных функций,
таких как предохранители. Однако для каждого языка программирования, который
вы используете, требуется отдельное шасси. Например, Spring Boot и Spring Cloud
подходят для разработчиков на Java/Spring, но они мало чем помогут, если вы пи
шете сервис на основе NodeJS.
У этого подхода появляется новая альтернатива, которая заключается в реали
зации этих функций в рамках так называемой сети сервисов. Сеть сервисов — это
сетевая инфраструктура, которая служит промежуточным звеном между вашим сер
висом и другими внутренними и внешними компонентами. На рис. 11.17 показано,
как весь входящий и исходящий трафик сервиса проходит через эту сеть, которая
берет на себя функции предохранителей, распределенной трассировки, обнаружения
сервисов, балансирования нагрузки и маршрутизации трафика с учетом правил.
Сеть сервисов может также защищать межсервисное взаимодействие с помощью
IPC на основе TLS. Благодаря этому больше не нужно реализовывать эти возмож
ности внутри сервисов.
11.4. Разработка сервисов с помощью шаблона микросервисного шасси 443
Р
и
с.
1
1.
17
. В
ес
ь
вх
од
ящ
ий
и
и
сх
од
ящ
ий
т
ра
ф
ик
с
ер
ви
са
п
ро
хо
ди
т
че
ре
з
се
ть
с
ер
ви
со
в,
к
от
ор
ая
р
еа
ли
зу
ет
ф
ун
кц
ии
п
ре
до
хр
ан
ит
ел
ей
,
ра
сп
ре
де
ле
нн
ой
т
ра
сс
ир
ов
ки
, о
бн
ар
уж
ен
ия
с
ер
ви
со
в
и
ба
ла
нс
ир
ов
ан
ия
н
аг
ру
зк
и.
О
ст
ал
ьн
ы
е
во
зм
ож
но
ст
и
м
ож
но
р
еа
ли
зо
ва
ть
в
м
ик
ро
се
рв
ис
но
м
ш
ас
си
. Т
ак
ж
е
се
ть
с
ер
ви
со
в
за
щ
ищ
ае
т
м
еж
пр
оц
ес
сн
ы
е
ко
м
м
ун
ик
ац
ии
с
п
ом
ощ
ью
T
LS
444 Глава И • Разработка сервисов, готовых к промышленному использованию
Использование сети сервисов значительно упрощает микросервисное шасси,
которое в этом случае отвечает только за функции, глубоко интегрированные в код
приложения, например за работу с внешней конфигурацией и проверку работоспо
собности. Микросервисное шасси также должно поддерживать распределенную
трассировку путем распространения такой информации, как заголовки стандарта
ВЗ, рассмотренные в подразделе 11.3.3.
Концепция сети сервисов очень многообещающая. Она освобождает разработчи
ка от реализации различных общих функций. Кроме того, благодаря маршрутизации
трафика вы можете отделить развертывание от выпуска обновлений. Это позволяет
развертывать новые версии в промышленной среде, делая их доступными только
для некоторых пользователей, например для внутренней команды тестирования.
Мы поговорим об этом подробнее в главе 12, где речь пойдет о развертывании сер
висов с помощью Kubernetes.
Резюме
□ Важно, чтобы сервис соответствовал функциональным требованиям, но вместе
с тем он должен быть безопасным, конфигурируемым и наблюдаемым.
□ В том, что касается безопасности, микросервисная и монолитная архитектуры
имеют много общего. Но есть и некоторые различия, например передача учетных
данных между API-шлюзом и сервисами или выбор компонента, ответственного
за аутентификацию и авторизацию. Аутентификацией клиентов обычно занима
ется API-шлюз. В каждый запрос к сервису он добавляет прозрачный токен, такой
как JWT. Он содержит учетные данные субъекта и его роли. Сервис использует
эту информацию для авторизации доступа к ресурсам. Хорошей основой для
безопасности в микросервисной архитектуре может быть стандарт OAuth 2.0.
□ Сервис обычно общается с одним или несколькими внешними компонентами,
такими как брокер сообщений или база данных. Их сетевое размещение и учетные
данные часто зависят от среды, в которой запущен сервис. Вы должны применить
шаблон вынесения конфигурации вовне и реализовать механизм, который предо-
Резюме 445
ставляет сервису конфигурационные свойства на этапе выполнения. Во многих
случаях инфраструктура развертывания делает эти свойства доступными через
переменные системного окружения или конфигурационный файл во время соз
дания экземпляра сервиса. В качестве альтернативы экземпляр сервиса может
сам извлекать свою конфигурацию из специального сервера.
□ Системные администраторы и разработчики разделяют ответственность за реали
зацию шаблонов наблюдаемости. Администраторы отвечают за соответствующую
инфраструктуру, такую как серверы для агрегации журналов, сбора показателей,
отслеживания исключений и распределенной трассировки. Разработчики долж
ны сделать так, чтобы их сервисы были наблюдаемыми. Сервис должен иметь
конечные точки для проверки работоспособности, генерировать журнальные
записи, собирать и «выставлять наружу» показатели, отправлять отчеты серверу
для отслеживания исключений и поддерживать распределенную трассировку.
□ Чтобы упростить и ускорить разработку, вы должны писать свои сервисы поверх
микросервисного шасси. Микросервисное шасси — это фреймворк или набор
фреймворков для реализации различных общих функций, включая те, что были
описаны в этой главе. Хотя, скорее всего, со временем многие сетевые обязан
ности перейдут от микросервисного шасси к сети сервисов — инфраструктурной
прослойке, через которую проходит весь сетевой трафик сервисов.
Развертывание
микросервисов
Мэри и ее команда уже почти закончили писать свой первый сервис. И хотя ему
все еще недостает некоторых функций, он успешно выполняется на ноутбуках раз
работчиков и сервере Jenkins CI. Но этого недостаточно. Чтобы приносить прибыль
компании FTGO, программное обеспечение должно быть развернуто в промышлен
ной среде и доступно пользователям. Разработчикам необходимо развернуть свой
сервис в промышленных условиях.
Развертывание — это сочетание двух взаимосвязанных концепций — процесса
и архитектуры. Процесс развертывания заключается в доставке кода в промышлен
ную среду и состоит из этапов, которые должны выполнить люди — разработчики
или системные администраторы. Архитектура развертывания определяет структуру
Глава 12. Развертывание микросервисов 447
среды, в который этот код будет выполняться. С конца 1990-х годов, когда я начал
писать промышленные Java-приложения, оба эти аспекта радикально изменились.
Процесс перебрасывания разработчиками кода на рабочий сервер вручную стал
высокоавтоматизированным. На смену физической промышленной среде пришла
легковесная динамическая вычислительная инфраструктура (рис. 12.1).
Рис. 12.1. Тяжеловесные компьютеры с продолжительным временем работы скрываются за все
более легковесными динамическими технологиями
В 1990-е годы, если вы хотели развернуть приложение в промышленной среде,
вам нужно было передать свой код вместе с набором инструкций по его обслужива
нию системным администраторам. Вы могли, к примеру, оформить заявку с прось
бой развернуть приложение. Все, что происходило после этого, ложилось на плечи
администраторов, исключением были ситуации, когда обнаруживалась проблема, ко
торую нельзя было решить без вашей помощи. Обычно системные администраторы
покупали и устанавливали дорогие и тяжеловесные серверы приложений, такие как
WebLogic или WebSphere. Затем заходили в консоль этих серверов и развертывали
ваш код. Они с любовью заботились об этих компьютерах, словно о собственных
питомцах, устанавливая заплатки и обновляя программное обеспечение.
В середине 2000-х на смену дорогим серверам приложений пришли открытые
легковесные веб-контейнеры наподобие Apache Tomcat и Jetty. Теперь стало воз
можным выделять целый веб-контейнер лишь для одного приложения, хотя это
не было обязательным требованием. Примерно в то же время вместо физического
оборудования начали применять виртуальные машины. Но к серверам по-прежнему
относились как к домашним животным, а развертывание, как и раньше, было прин
ципиально ручным процессом.
448 Глава 12 • Развертывание микросервисов
В наши дни процесс развертывания выглядит совсем иначе. Вместо передачи
кода отдельной команде системных администраторов используется концепция
DevOps, согласно которой ответственность за развертывание приложений и сер
висов частично ложится и на разработчиков. В некоторых организациях адми
нистраторы предоставляют разработчикам консоль для развертывания проектов.
Но еще лучше, когда после прохождения тестов код автоматически доставляется
в промышленную среду.
Радикально изменились и вычислительные ресурсы, которые используются
в промышленной среде. Вместо физических серверов мы имеем дело с виртуаль
ными машинами, запущенными в высокоавтоматизированных облаках наподобие
AWS. Современные ВМ неизменяемы. Это уже не домашние любимцы, а безликие
одноразовые сущности, которые легче удалить и воссоздать, чем конфигурировать
заново. Все большую популярность набирают контейнеры — еще более легковесный
слой абстракции поверх виртуальных машин. Дальнейшее развитие этой идеи —
бессерверные платформы развертывания, такие как AWS Lambda, которые имеют
множество сфер применения.
Эволюция процессов и архитектур развертывания не случайно происходит одно
временно с ростом популярности микросервисов. Приложение может состоять из
десятков и сотен сервисов, написанных с помощью разных языков и фреймворков.
Поскольку каждый сервис автономен, это означает, что в вашей промышленной сре
де могут находиться десятки и сотни программ. Конфигурацию серверов и сервисов
больше не имеет смысла поручать администраторам. Если вы хотите развертывать
свои микросервисы в крупных масштабах, вам необходимы высокоавтоматизиро
ванные процесс и инфраструктура.
На рис. 12.2 представлена обобщенная схема промышленной среды, которая
позволяет разработчикам конфигурировать и администрировать свои сервисы, про
цессу развертывания — доставлять новые версии кода, а пользователям — получать
доступ к возможностям, реализованным этими сервисами.
Промышленная среда должна поддерживать четыре ключевые возможности.
□ Интерфейс управления сервисами позволяет разработчикам создавать, обновлять
и конфигурировать сервисы. В идеале это интерфейс REST API, с которым рабо
тают консольные и графические инструменты развертывания.
□ Управление запущенными сервисами пытается следить за тем, чтобы в промыш
ленной среде всегда выполнялось желаемое количество экземпляров серви
са. Если экземпляр сервиса вышел из строя или по какой-то причине больше
не может обслуживать запросы, промышленная среда должна его перезапустить.
Если отказал целый сервер, все его сервисы должны быть перезапущены на дру
гом компьютере.
□ Мониторинг предоставляет разработчикам сведения о работе их сервисов, вклю
чая журнальные файлы и показатели. В случае возникновения проблем про
мышленная среда должна оповестить разработчиков. Мониторинг (или наблю
даемость) описывается в главе 11.
□ Маршрутизация направляет запросы от пользователей к сервисам.
В этой главе обсуждаются четыре основных варианта развертывания.
12.1. Развертывание сервисов с помощью пакетов для отдельных языков 449
Рис. 12.2. Упрощенная схема промышленной среды. Она предоставляет четыре основные
возможности: управление сервисами позволяет разработчикам развертывать и администрировать
свой код, управление на этапе выполнения обеспечивает работу сервисов, мониторинг
визуализирует поведение сервисов и генерирует оповещения, маршрутизация направляет
сервисам запросы пользователей
□ Развертывание сервиса в виде пакетов для определенных языков, таких как
JAR- или WAR-файлы в Java. Этот подход заслуживает внимания в качестве
демонстрации недостатков, из-за которых я рекомендую использовать один из
других вариантов.
□ Применение виртуальных машин упрощает процесс развертывания, так как
сервисы упаковываются в виде образов для ВМ, которые инкапсулируют их
технологический стек.
□ Развертывание сервисов в виде контейнеров, более легковесных по сравнению
с виртуальными машинами. Я покажу, как развернуть сервис Restaurant из при
ложения FTGO с помощью популярного фреймворка для оркестрации Docker
под названием Kubernetes.
□ Бессерверное развертывание является даже более современным, чем контейнеры.
Мы посмотрим, как развернуть сервис Restaurant с помощью популярной бес-
серверной платформы AWS Lambda.
Обсудим развертывание сервисов в виде пакетов для определенных языков.
12.1. Развертывание сервисов
с помощью пакетов для отдельных языков
Представьте, что вам нужно развернуть сервис Restaurant из приложения FTGO,
написанный на Java с применением Spring Boot. Для этого можно воспользоваться
форматом пакетов, предназначенным для определенного языка. При использовании
этого подхода пакет развертывается в промышленную среду, а управляет им среда
450 Глава 12 • Развертывание микросервисов
выполнения сервиса. В случае с сервисом Restaurant это будет исполняемый файл
формата JAR или WAR. В других языках, таких как NodeJS, сервис представляет со
бой каталог с исходным кодом и модулями. Такие языки, как Go Lang, компилируют
сервис в исполняемый файл конкретной операционной системы.
Чтобы развернуть сервис Restaurant, нужно сначала установить на компьютер
необходимую среду выполнения — в данном случае это JDK. Если вы используете
WAR-файл, вам также нужно установить веб-контейнер, такой как Apache Tomcat.
Подготовив компьютер, вы должны скопировать на него свой пакет и запустить
сервис. Каждый экземпляр сервиса выполняется в виде процесса JVM.
В идеале ваш процесс развертывания должен уметь автоматически доставлять
сервисы в промышленную среду (рис. 12.3). Сначала собирается файл формата JAR
или WAR. Затем вызывается интерфейс управления сервисами промышленной
среды, который развертывает новую версию.
Рис. 12.3. Процесс развертывания собирает исполняемый JAR-файл и доставляет его
в промышленную среду. В промышленной среде каждый экземпляр сервиса представлен
JVM-машиной, запущенной на компьютере с установленным пакетом JDK или JRE
Экземпляр сервиса обычно (но не всегда) представлен единственным процес
сом. Например, в Java это процесс, в котором выполняется JVM. Сервис на основе
NodeJS может породить несколько рабочих процессов для параллельной обработки
запросов. Некоторые языки поддерживают развертывание множества экземпляров
сервиса в рамках одного процесса.
12.1. Развертывание сервисов с помощью пакетов для отдельных языков 451
Иногда компьютер обслуживает лишь один экземпляр сервиса, но при этом вы
можете развернуть на нем дополнительные экземпляры. Например, вы можете за
пустить на одном сервере несколько JVM-машин (рис. 12.4). Каждая JVM-машина
выполняет отдельный экземпляр сервиса.
Рис. 12.4. Развертывание нескольких экземпляров сервиса на одном компьютере. Эти экземпляры
могут принадлежать одному и тому же или разным сервисам. Ресурсы операционной системы
распределяются между экземплярами. Каждый экземпляр представлен отдельным процессом,
что обеспечивает некоторую их изоляцию
Некоторые языки позволяют запускать несколько экземпляров сервиса в одном
процессе. Например, вы можете запустить несколько Java-сервисов в одном контей
нере Apache Tomcat (рис. 12.5).
Рис. 12.5. Развертывание нескольких экземпляров сервиса в одном и том же веб-контейнере
или сервере приложений. Эти экземпляры могут принадлежать одному и тому же или разным
сервисам. Ресурсы операционной системы распределяются между экземплярами. Но поскольку
они запущены в одном процессе, то никак не изолированы
Этот подход обычно используют при развертывании кода в традиционных до
рогих и тяжеловесных серверах приложений, таких как Web Logic и WebSphere.
Сервисы можно также упаковывать в формате OSGI и запускать по нескольку их
экземпляров в каждом OSGI-контейнере.
452 Глава 12 • Развертывание микросервисов
Применение пакетов, характерных для тех или иных языков, имеет положитель
ные и отрицательные последствия. Начнем с положительных.
12.1.1. Преимущества использования пакетов
для конкретных языков
Задействование пакетов для конкретных языков имеет несколько преимуществ:
□ быстрое развертывание;
□ эффективное задействование ресурсов, особенно при запуске нескольких экзем
пляров на одном компьютере или внутри одного процесса.
Рассмотрим их.
Быстрое развертывание
Одно из важнейших преимуществ этого подхода состоит в относительно высокой
скорости развертывания экземпляров сервиса. Если сервис написан на Java, вы мо
жете просто скопировать файл JAR или WAR. При использовании других языков,
таких как NodeJS или Ruby, нужно скопировать исходный код. В любом случае
объем информации, копируемой по сети, получается довольно небольшим.
Кроме того, запуск сервисов обычно не занимает много времени. Если сервис
находится в отдельном процессе, вы можете его просто запустить. Если же это один
из нескольких экземпляров, размещенных в процессе одного контейнера, у вас
есть два варианта: развернуть его динамически или перезапустить весь контейнер.
Благодаря отсутствию накладных расходов перезапуск сервиса обычно происходит
очень быстро.
Эффективное использование ресурсов
Еще одно преимущество данного подхода связано с довольно эффективным по
треблением ресурсов. Множество экземпляров сервиса находятся на одном ком
пьютере и работают в одной ОС. Эффективность может возрасти еще сильнее, если
несколько экземпляров сервиса запустить в одном процессе. Например, разные
веб-приложения могут задействовать один и тот же сервер Apache Tomcat и JVM.
12.1.2. Недостатки применения пакетов
для конкретных языков
Несмотря на свою привлекательность, использование пакетов для конкретных язы
ков имеет несколько существенных недостатков:
□ отсутствие инкапсуляции стека технологий;
□ невозможность ограничения ресурсов, потребляемых экземпляром сервиса;
12.1. Развертывание сервисов с помощью пакетов для отдельных языков 453
□ недостаточная изоляция при запуске нескольких экземпляров сервиса на одном
компьютере;
□ трудности автоматического определения места размещения экземпляров сервиса.
Рассмотрим каждый из этих пунктов.
Отсутствие инкапсуляции стека технологий
Команде системных администраторов должны быть известны подробности раз-
вертывания каждого отдельного сервиса, включая конкретную версию среды вы
полнения. Например, веб-приложению, написанному Hajava, нужны определенные
версии Apache Tomcat и JDK. Администраторы должны позаботиться об установке
подходящих программных пакетов.
Но что еще хуже, сервисы могут быть написаны на разных языках и с примене
нием разных фреймворков. Также они могут использовать разные версии одних
и тех же языков и библиотек. Поэтому команда разработчиков должна сообщать
администраторам множество подробностей. Ведь, например, на компьютере может
быть установлена не та версия среды выполнения.
Невозможность ограничения ресурсов, потребляемых
экземпляром сервиса
Еще один недостаток состоит в том, что вы не можете ограничить ресурсы, потребля
емые экземпляром сервиса. Процесс потенциально может занять все процессорное
время или память, отнимая ресурсы у других экземпляров сервиса и операционной
системы. Это может произойти, к примеру, из-за ошибки в коде.
Недостаточная изоляция при запуске нескольких экземпляров
сервиса на одном компьютере
Проблема усугубляется, когда несколько экземпляров выполняются на одном ком
пьютере. Недостаточная изоляция означает, что один вышедший из строя экземпляр
может повлиять на работу других. В итоге приложение может стать ненадежным,
особенно при запуске нескольких экземпляров сервиса на одном компьютере.
Трудности автоматического определения места размещения
экземпляров сервиса
Еще одно затруднение при выполнении нескольких экземпляров сервиса па одном
компьютере связано с определением того, где их разместить. Объем процессорного
времени, памяти и т. д. у каждого сервера ограничен, и каждому экземпляру сервиса
нужна какая-то часть этих ресурсов. Экземпляры сервисов следует размещать так,
чтобы они эффективно использовали оборудование и не вызывали перегрузок.
Чуть позже вы узнаете, что облачные платформы на основе ВМ и фреймворки
454 Глава 12 • Развертывание микросервисов
оркестрации контейнеров делают это автоматически. При развертывании сервисов
без каких-либо абстракций вам, скорее всего, придется самостоятельно управлять
размещением.
Как видите, привычное применение пакетов для конкретных языков все же имеет
существенные недостатки. Вам следует избегать этого подхода, за исключением тех
случаев, когда эффективность перевешивает все остальное.
Теперь рассмотрим современные способы развертывания сервисов, у которых
нет этих проблем.
12.2. Развертывание сервисов
в виде виртуальных машин
Опять-таки представьте, что вам нужно развернуть сервис Restaurant, но на этот
раз в AWS ЕС2. Для этого вы можете создать и настроить сервер ЕС2 и скопиро
вать на него исполняемый файл или архив WAR. В этом случае вы бы получили
определенные преимущества от использования облака, но у этого подхода те же не
достатки, что были описаны в предыдущем разделе. Более современным решени
ем будет упаковать сервис в формате AMI (Amazon Machine Image) (рис. 12.6).
Каждый экземпляр сервиса будет представлен сервером ЕС2, созданным из этого
AMI-пакета. Серверами ЕС2 обычно управляет группа автомасштабирования
AWS, которая пытается поддерживать желаемое количество работоспособных
экземпляров.
Образ виртуальной машины собирается в процессе развертывания сервиса.
Процесс развертывания запускает сборщик образов ВМ, чтобы создать образ
с кодом сервиса и тем программным обеспечением, которое ему нужно для работы
(см. рис. 12.6). Например, в случае с проектом FTGO сборщик ВМ устанавливает
JDK и исполняемый JAR-файл сервиса. Он также настраивает виртуальную ма
шину для запуска приложения с помощью системы инициализации Linux, такой
как Upstart.
Существует множество инструментов, с помощью которых процесс развертыва
ния может собрать образ ВМ. Одна из первых утилит для создания образов ЕС2
AMI называлась Aminator, она была создана компанией Netflix для развертывания
видеовещательного сервиса на AWS (https://github.com/Netflix/aminator). Более совре
менный сборщик образов ВМ — Packer. В отличие от Aminator он поддерживает
12.2. Развертывание сервисов в виде виртуальных машин 455
различные технологии виртуализации, включая ЕС2, Digital Ocean, Virtual Box
и VMware (www.packer.io). Для его использования нужно написать конфигурацион
ный файл, в котором указать базовый образ и набор средств подготовки для уста
новки и конфигурации AMI.
Рис. 12.6. Процесс развертывания упаковывает сервис в виде образа ВМ, такого как ЕС2 AMI,
который содержит все необходимое для его работы, включая среду выполнения языка. На этапе
выполнения каждый экземпляр сервиса представлен ВМ, такой как сервер ЕС2, основанной на этом
образе. Балансировщик нагрузки Elastic направляет запросы к этим экземплярам
456 Глава 12 • Развертывание микросервисов
Теперь рассмотрим положительные и отрицательные стороны этого подхода.
12.2.1. Преимущества развертывания сервисов
в виде ВМ
Развертывание сервисов в виде ВМ имеет ряд преимуществ.
□ Образ ВМ инкапсулирует стек технологий.
□ Экземпляры сервиса изолированы.
□ Используется зрелая облачная инфраструктура.
Давайте их рассмотрим.
Образ ВМ инкапсулирует стек технологий
Важное преимущество этого шаблона состоит в том, что образ ВМ содержит сервис
вместе со всеми его зависимостями. Это избавляет от необходимости устанавливать
и конфигурировать ПО, нужное для работы сервиса, что может спасти от потен
циальных ошибок. После упаковки в образ ВМ сервис становится своеобразным
черным ящиком, который инкапсулирует свой стек технологий. Такой образ можно
развертывать где угодно, не внося изменений. API для развертывания сервиса ста
новится интерфейсом для управления ВМ, а сам процесс существенно упрощается
и становится более надежным.
Экземпляры сервиса изолированы
Существенным преимуществом виртуальных машин является то, что экземпляры
сервисов выполняются в полной изоляции. Это, в конце концов, основная цель
данной технологии. Каждая ВМ имеет фиксированное количество процессорного
времени и памяти, что не позволяет ей занимать ресурсы других сервисов.
Использование зрелой облачной инфраструктуры
Еще одна положительная сторона развертывания микросервисов в виде виртуальных
машин — возможность задействования зрелой, высокоавтоматизированной облач
ной инфраструктуры. Публичные облака, например AWS, пытаются запланировать
12.2. Развертывание сервисов в виде виртуальных машин 457
работу ВМ на физических серверах так, чтобы избежать перегрузок. Они также
предоставляют полезные возможности — автомасштабирование и балансирование
трафика между ВМ.
12.2.2. Недостатки развертывания сервисов
в виде ВМ
Развертывание сервисов в виде ВМ имеет и недостатки.
□ Менее эффективно используются ресурсы.
□ Развертывание протекает довольно медленно.
□ Требуются дополнительные расходы на системное администрирование.
Рассмотрим их.
Менее эффективное использование ресурсов
Каждый экземпляр сервиса тянет за собой целую виртуальную машину, включая ее
операционную систему. Более того, публичные платформы IaaS обычно предлага
ют ограниченный набор размеров ВМ, поэтому ваши ресурсы, скорее всего, будут
использоваться не на полную мощь. Это в меньшей мере относится к сервисам,
основанным на Java, поскольку они относительно тяжеловесны. Но развертывание
таким образом легковесных сервисов, написанных на NodeJS или Go Lang, может
оказаться неэффективным.
Довольно медленное развертывание
Сборка образа ВМ обычно исчисляется минутами из-за размера виртуальной маши
ны. Для этого по сети нужно передать довольно много данных. Создание экземпляра
ВМ из образа тоже требует некоторого времени — опять-таки из-за количества
данных, перемещаемых по сети. К тому же операционная система, выполняемая
внутри ВМ, загружается не сразу, хотя понятие «медленно» является относительным.
Процесс может растянуться на минуты, но это все равно быстрее, чем традиционное
развертывание. Вместе с тем он значительно уступает по скорости более легковес
ным шаблонам, с которыми вы вскоре познакомитесь.
Дополнительные расходы
на системное администрирование
Вы сами отвечаете за обновление операционной системы и среды выполнения.
Системное администрирование может показаться неотъемлемой частью разверты
вания ПО, но в разделе 12.5 я опишу бессерверное развертывание, в котором этот
аспект полностью искоренен.
Теперь поговорим об альтернативных способах развертывания микросервисов,
которые, несмотря на свою легковесность, обладают многими преимуществами ВМ.
458 Глава 12 • Развертывание микросервисов
12.3. Развертывание сервисов в виде контейнеров
Контейнеры — это более легковесный современный механизм развертывания. Они ис
пользуют механизм виртуализации на уровне операционной системы. Контейнер
обычно состоит из одного процесса (хотя их может быть несколько), запущенного
в среде, изолированной от других контейнеров (рис. 12.7). Например, контейнер
с Java-сервисом, как правило, состоит из процесса JVM.
Рис. 12.7. Контейнер состоит из одного или нескольких процессов, запущенных
в изолированной среде. На одном компьютере обычно запускается несколько контейнеров,
использующих общую операционную систему
Процесс, запущенный внутри контейнера, ведет себя так, словно ему отведен
целый отдельный сервер. Обычно он имеет собственный IP-адрес, что позволяет из
бежать конфликтов с портами, — все Java-процессы, к примеру, могут прослушивать
порт 8080. У каждого контейнера есть своя корневая файловая система. Для изоля
ции контейнеров друг от друга их среда выполнения использует механизмы опера
ционной системы. Наиболее популярная среда выполнения контейнеров — Docker,
есть и альтернативы, такие как Solaris Zones.
12.3. Развертывание сервисов в виде контейнеров 459
При создании контейнера можно указать его процессор, объем памяти и в зависи
мости от реализации ресурсы ввода/вывода. Среда выполнения контейнеров следит
за соблюдением этих ограничений и не дает им занять все ресурсы компьютера.
Это особенно важно при использовании фреймворков оркестрации Docker, таких
как Kubernetes, так как эти лимиты учитываются при выборе серверов для запуска
контейнеров, что позволяет избежать их перегрузки.
На рис. 12.8 показан процесс развертывания сервиса в виде контейнера. На эта
пе сборки применяет специальный инструмент, который считывает код сервиса
и описание его образа, чтобы создать образ контейнера и сохранить его в реестр.
Во время выполнения этот образ извлекается из реестра и используется для созда
ния контейнеров.
Давайте подробнее рассмотрим этапы сборки и выполнения.
Рис. 12.8. Сервис упаковывается в виде образа контейнера, который хранится в реестре. Во время
выполнения сервис состоит из нескольких контейнеров, созданных из этого образа. Контейнеры
обычно запускаются в виртуальных машинах. Одна ВМ, как правило, выполняет несколько
контейнеров
460 Глава 12 • Развертывание микросервисов
12.3.1. Развертывание сервисов с помощью Docker
Чтобы развернуть сервис в виде контейнера, его необходимо упаковать в образ.
Образ контейнера — это слепок файловой системы, состоящий из приложения
и любого ПО, которое требуется для его работы. Часто для этого берут полноценную
корневую файловую систему Linux, хотя встречаются и более легковесные образы.
Например, чтобы развернуть сервис на основе Spring Boot, нужно собрать образ
контейнера, в который входят исполняемый JAR-файл сервиса и подходящая вер
сия JDK. Точно так же для развертывания веб-приложения, написанного на Java,
собирается образ контейнера с WAR-файлом, Apache Tomcat и JDK.
Сборка образа Docker
Первым шагом при сборке образа становится создание файла Dockerfile, который
описывает то, как Docker будет собирать этот образ. В нем указываются базовый
образ контейнера, последовательность инструкций для установки ПО и конфи
гурации контейнера и команда оболочки, выполняемая при создании контейнера.
В листинге 12.1 показан Dockerfile, который собирает образ для сервиса Restaurant.
Результат будет содержать исполняемый JAR-файл этого сервиса. Контейнер кон
фигурируется для выполнения при запуске команды java - jar.
Листинг 12.1. Dockerfile для сборки сервиса Restaurant
Базовый образ Устанавливаем curl
FROM openjdk:8ul71-jre-alpine
RUN apk --no-cache add curl
для проверки
работоспособности
CMD java ${JAVA_OPTS} -jar ftgo-restaurant-service.jar <
Настраиваем Docker
для выполнения java -jar..
при запуске контейнера
HEALTHCHECK --start-period=30s --
interval=5s CMD curl http://localhost:8080/actuator/health || exit 1
COPY build/libs/ftgo-restaurant-service.jar
Копирует JAR из каталога
сборки Gradle в образ
Делаем так, чтобы Docker обратился
к конечной точке для проверки
работоспособности
openjdk:8ul71-jre-alpine — это минимальный образ Linux с содержащимся в нем
JRE. Dockerfile копирует JAR-архив сервиса в этот образ и конфигурирует контей
нер для выполнения этого архива во время запуска. Он также настраивает Docker
для периодического обращения к конечной точке с данными о работоспособности
(см. главу 11). Директива HEALTHCHECK приводит к вызову этой конечной точки раз
в 5 с после начальной 30-секундной задержки, которая дает сервису время на запуск.
Написав Dockerfile, вы можете собрать образ. В листинге 12.2 показаны команды
оболочки, которые собирают образ для сервиса Restaurant. Этот скрипт компили
рует J AR-файл с содержащимся в нем сервисом и выполняет команду docker build
для создания образа.
12.3. Развертывание сервисов в виде контейнеров 461
Листинг 12.2. Командные оболочки, которые собирают образ для сервиса Restaurant
I Переходим в каталог сервиса
cd ftgo-restaurant-service ◄------ ' I Компилируем JAR сервиса
../gradlew assemble ◄----------- '
docker build -t ftgo-restaurant-service . ◄------ 1
| Собираем образ
У команды docker build есть два аргумента: ключ -t, определяющий имя образа,
и . — то, что в терминологии Docker называется контекстом. Контекст в данном
случае является текущим каталогом и состоит из Dockerfile и файлов для сборки
образа. Команда docker build передает контекст демону Docker, который выполняет
сборку.
Загрузка образа Docker в реестр
Последний шаг в процессе сборки — загрузка свежесозданного образа Docker
в так называемый реестр. Реестр Docker — это эквивалент репозитория Maven
для библиотек Java или реестра NPM для пакетов NodeJS. Docker Hub служит
примером публичного реестра Docker и эквивалентом Maven Central и NpmJS.org.
Но в своих проектах вы, скорее всего, будете использовать приватный реестр,
предоставляемый такими сервисами, как Docker Cloud Registry или AWS ЕС2
Container Registry.
Для загрузки образа в реестр необходимо выполнить две команды. Первая коман
да, docker tag, присваивает образу название с сетевым именем в качестве префикса,
а также указывает порт реестра, если это необходимо. Название образа содержит
также суффикс в виде номера версии — это будет важно при обновлении сервиса.
Например, если сетевое имя реестра равно registry.acme.com, для маркирования
образа нужно использовать следующую команду:
docker tag ftgo-restaurant-service registry.acme.com/ftgo-restaurant-
service:1.0.0.RELEASE
Затем выполняется команда docker push, которая загружает промаркированный
образ в реестр:
docker push registry.acme.com/ftgo-restaurant-service:1.0.©.RELEASE
Эта команда обычно занимает намного меньше времени, чем можно было бы
ожидать. Дело в том, что образы Docker имеют так называемую многослойную фай
ловую систему, что позволяет передавать по сети только их часть. Операционная
система образа, среда выполнения Java и само приложение находятся в разных слоях.
Docker нужно передать только те слои, которых не существует в реестре. В итоге,
если передается только слой приложения, который обычно занимает лишь неболь
шую часть образа, загрузка происходит довольно быстро.
Разобравшись с загрузкой образа, мы можем перейти к созданию контейнера.
462 Глава 12 • Развертывание микросервисов
Запуск контейнера Docker
Упаковав свой сервис в виде образа, можете его запустить. Инфраструктура Docker
загрузит образ из реестра на рабочий сервер и создаст из него один или несколько
контейнеров. Каждый контейнер будет служить экземпляром вашего сервиса.
Как можно было бы ожидать, Docker предоставляет команду docker run, которая
создает и запускает контейнер. Использование этой команды на примере сервиса
Restaurant продемонстрировано в листинге 12.3. Она имеет несколько аргументов,
включая название образа и переменные окружения, которые будут заданы в среде
выполнения контейнера. Последние применяются для передачи внешней конфигу
рации, такой как сетевое размещение базы данных и пр.
Листинг 12.3. Использование команды docker run для запуска контейнера сервиса
docker run \ | Запускает его в виде фонового демона
-е SPRING_DATASOURCE_URL=... -е SPRING_DATASOURCE_USERNAME=... \
-е SPRING_DATASOURCE_PASSWORD=... \
registry.acme.com/ftgo-restaurant-service:1.0.0.RELEASE ◄—i
| Запускаемый образ
Команда docker run достает образ из реестра, если это необходимо. Затем она
создает и запускает контейнер, который выполняет команду java - jar, указанную
в Dockerfile.
На первый взгляд, команда docker run выглядит довольно просто, но у нее есть
несколько проблем. Прежде всего, она не обеспечивает надежное развертывание сер
виса, поскольку созданный ею контейнер запускается лишь на одном компьютере.
Ядро Docker предоставляет некоторые базовые возможности управления, такие как
автоматический перезапуск контейнера, если тот вышел из строя (или в случае пере
загрузки сервера). Но при этом не учитывается риск отказа родительской системы.
Еще одна проблема связана с тем, что сервисы обычно не существуют сами по
себе. Им нужны другие компоненты, такие как база данных и брокер сообщений.
Было бы неплохо развертывать и удалять сервис как единое целое со всеми его за
висимостями.
Лучшим решением, особенно на этапе разработки, является использование Docker
Compose. Docker Compose — это инструмент для декларативного описания набора
контейнеров в виде файла YAML с возможностью их последующего группового за
пуска и остановки. Кроме того, в файле YAML удобно указывать многочисленные
свойства внешней конфигурации. Для более тесного знакомства с Docker Compose
я рекомендую прочитать книгу Джеффа Николоффа (Jeff Nickoloff) Docker in Action
(Manning, 2016) и просмотреть файл docker-compose, у ml в примере кода.
Однако у Docker Compose есть одна проблема — он ограничен одним компьюте
ром. Для надежного развертывания сервисов необходимо задействовать фреймворк
оркестрации Docker, такой как Kubernetes, который превращает набор серверов
в пул ресурсов. Я покажу, как использовать Kubernetes, в разделе 12.4. А сначала
рассмотрим положительные и отрицательные стороны контейнеризации.
12.4. Развертывание приложения FTGO с помощью Kubernetes 463
12.3.2. Преимущества развертывания сервисов
в виде контейнеров
Развертывание сервисов в виде контейнеров имеет несколько преимуществ, которые
в основном свойственны и виртуальным машинам,
□ Инкапсуляция стека технологий, благодаря которой API для управления серви
сом превращается в API контейнера.
□ Экземпляры сервиса изолированы.
□ Ресурсы экземпляров сервиса ограничены.
Но, в отличие от виртуальных машин, контейнеры — это легковесная технология.
Сборка их образов обычно протекает быстро. Например, на упаковку приложения
на основе Spring Boot в виде образа контейнера мой ноутбук тратит всего 5 с. Пере
мещение образов по сети, например в реестр и из него, тоже происходит довольно
быстро — в основном благодаря тому, что передаются лишь некоторые его слои.
Еще контейнеры очень быстро запускаются, минуя длительный процесс загрузки ОС.
Запуску подлежит лишь сам сервис.
12.3.3. Недостатки развертывания сервисов
в виде контейнеров
Существенный недостаток контейнеров состоит в том, что вы должны постоянно
заниматься управлением образами контейнеров и обновлением операционной си
стемы вместе со средой выполнения. Кроме того, если вы не применяете облачные
контейнерные решения наподобие Google Container Engine или AWS ECS, на вас
ложится администрирование контейнерной инфраструктуры и, возможно, инфра
структуры виртуальных машин, поверх которой она работает.
12.4. Развертывание приложения FTGO
с помощью Kubernetes
Итак, мы рассмотрели контейнеры и взвесили их плюсы и минусы. Теперь по
говорим о том, как развернуть сервис Restaurant приложения FTGO с помощью
Kubernetes. Утилита Docker Compose, описанная в разделе 12.3.1, отлично подходит
для разработки и тестирования. Но для надежного запуска контейнеризированных
сервисов в промышленных условиях нужно более продвинутое решение, такое как
Kubernetes. Kubernetes — это фреймворк оркестрации Docker, программный слой по
верх Docker, который объединяет набор серверов в единый пул ресурсов для запуска
сервисов. Он постоянно пытается поддерживать желаемое количество запущенных
экземпляров сервиса, даже в случае возникновения неполадок в этих экземплярах
или серверах. Легкость контейнеров и богатые возможности Kubernetes выводят
развертывание сервисов на новый уровень.
464 Глава 12 • Развертывание микросервисов
Я начну этот раздел с обзора платформы Kubernetes, ее возможностей и архитек
туры. Вслед за этим покажу, как с ее помощью развертывать сервисы. Kubernetes —
это сложная система, и ее исчерпывающий анализ выходит за рамки книги. Я лишь
покажу, как ее могут применять разработчики. Больше информации можно найти
в книге Марко Луксы (Marko Luksa) Kubernetes in Action (Manning, 2018).
12.4.1. Обзор Kubernetes
Kubernetes — это фреймворк оркестрации Docker, который обращается с набором
серверов под управлением Docker, как с пулом ресурсов. Вы лишь указываете,
сколько экземпляров сервиса нужно запустить, а фреймворк делает все остальное.
Архитектура фреймворка оркестрации Docker представлена на рис. 12.9.
Рис. 12.9. Фреймворк оркестрации Docker превращает набор серверов под управлением Docker
в кластер ресурсов. Он назначает контейнеры определенным серверам и постоянно пытается
поддерживать желаемое количество работоспособных контейнеров
Фреймворк оркестрации Docker, такой как Kubernetes, имеет три основные
функции.
□ Управление ресурсами. Обращается с кластером серверов как с пулом процессо
ров, памяти и томов хранилища, объединяя их в единый абстрактный компьютер.
12.4. Развертывание приложения FTGO с помощью Kubernetes 465
□ Планирование. Выбирает сервер для выполнения вашего контейнера. По умол
чанию планировщик учитывает требования к ресурсам, предъявляемые контей
нером, и свободные ресурсы на каждом узле. Он также поддерживает принципы
принадлежности (affinity) и непринадлежности (anti-affinity), которые позволяют
размещать контейнеры на одном и том же или на разных узлах.
□ Управление сервисом. Реализует концепцию именованных и версионных сер
висов, которые накладываются непосредственно на сервисы в микросервисной
архитектуре. Фреймворк оркестрации постоянно поддерживает желаемое ко
личество работоспособных экземпляров и распределяет между ними запросы.
Он также выполняет плавающие обновления сервиса и позволяет откатиться
к предыдущей версии.
Фреймворки оркестрации Docker все чаще применяются для развертывания
приложений. Фреймворк Docker Swarm является частью ядра Docker, поэтому он
прост в настройке и использовании. Платформу Kubernetes намного сложнее кон
фигурировать и администрировать, но у нее значительно больше возможностей.
На момент написания книги она переживает взрывной рост популярности и имеет
огромное сообщество. Посмотрим, как она работает.
Архитектура Kubernetes
Kubernetes выполняется на кластере компьютеров. Архитектура кластера Kubernetes
показана на рис. 12.10. Каждый компьютер в этом кластере является либо ведущим,
либо служебным узлом. Обычно ведущих узлов очень мало (иногда один), а рабо
чих — много. Ведущий компьютер отвечает за управление кластером. Рабочий узел
выполняет одну или несколько pod-оболочек. Pod-оболочка — это единица развер
тывания в Kubernetes, состоящая из набора контейнеров.
Ведущий узел содержит несколько компонентов, включая следующие:
□ API-сервер — интерфейс REST API для развертывания и администрирования
сервисов. Используется, к примеру, утилитой командной строки kubectl;
□ Etcd — база данных NoSQL типа «ключ — значение», хранящая данные кластера;
□ планировщик — выбирает узел для запуска pod-оболочки;
□ диспетчер контроллеров — запускает контроллеры, которые следят за тем, чтобы
кластер находился в нужном состоянии. Например, контроллер репликации (это
лишь один из типов контроллеров) обеспечивает выполнение желаемого коли
чества экземпляров сервиса, отвечая за их запуск и удаление.
Рабочий узел тоже выполняет несколько компонентов, включая следующие:
□ Kubelet — создает pod-оболочки и управляет их выполнением на рабочем узле;
□ Kube-proxy — управляет сетевыми функциями, включая балансирование нагрузки
между pod-оболочками;
□ pod-оболочки — сервисы приложения.
466 Глава 12 • Развертывание микросервисов
Рис. 12.10. Кластер Kubernetes состоит из ведущих и рабочих узлов, первые отвечают
за управление кластером, а последние выполняют сервисы. Разработчики и процесс развертывания
взаимодействуют с Kubernetes через API-сервер, который вместе с другим управляющим ПО
находится на ведущем узле. Контейнеры приложения выполняются на рабочих узлах. Каждый
рабочий узел запускает утилиты Kubelet и Kube-proxy, первая управляет контейнером приложения,
а вторая направляет пользовательские запросы к pod-оболочкам — либо напрямую в качестве
прокси, либо опосредованно, конфигурируя правила маршрутизации брандмауэра iptables,
встроенного в ядро Linux
Теперь рассмотрим ключевые концепции, которыми вам нужно овладеть для
того, чтобы развертывать сервисы в Kubernetes.
Ключевые концепции Kubernetes
Как упоминалось во введении к этому разделу, Kubernetes — довольно сложная
система. Однако для ее продуктивного использования достаточно овладеть не
сколькими ключевыми концепциями, которые называются объектами. Kubernetes
поддерживает объекты многих типов. С точки зрения разработчика самые важные
из них следующие.
12.4. Развертывание приложения FTGO с помощью Kubernetes 467
□ Pod-оболочка. Это базовая единица развертывания в Kubernetes. Она состоит
из одного или нескольких контейнеров с общими IP-адресом и томами хране
ния. Pod-оболочка экземпляра сервиса часто содержит лишь один контейнер,
который, к примеру, выполняет JVM. Но в некоторых случаях в ее состав может
входить несколько дополнительных контейнеров, реализующих вспомогательные
функции. Например, у сервера NGINX может быть дополнительный контейнер,
который периодически выполняет команду git pull, загружая последнюю версию
веб-сайта. Pod-оболочка является временной, поскольку ее контейнер или узел,
на котором она выполняется, могут выйти из строя.
□ Развертывание. Декларативная спецификация pod-оболочки. Это контроллер,
который постоянно обеспечивает нужное количество запущенных экземпляров
сервиса (pod-оболочки). Для поддержки версионирования он использует плава
ющие обновления и откаты. В подразделе 12.4.2 вы увидите, что в терминологии
Kubernetes каждый сервис в микросервисной архитектуре является развертыва
нием.
□ Сервис. Предоставляет клиентам сервиса статический/стабильный сетевой адрес.
Это разновидность механизма обнаружения сервисов на уровне инфраструктуры,
описанного в главе 3. Сервис имеет IP-адрес и DNS-имя, которое на него указы
вает, TCP- и UDP-трафик распределяются между несколькими pod-оболочками,
если их больше одной. IP-адрес и DNS-имя доступны только внутри Kubernetes.
Позже я покажу, как сконфигурировать сервисы таким образом, чтобы они были
доступны из-за пределов кластера.
□ ConfigMap. Именованный набор пар «имя — значение», который описывает внеш
нюю конфигурацию для одного или нескольких сервисов приложения (краткий
обзор вынесения конфигурации вовне сделан в главе 11). Определение контейнера
pod-оболочки может ссылаться на ConfigMap для перечисления переменных окру
жения. Оно может использовать ConfigMap также для создания конфигурационных
файлов внутри контейнера. Вы можете хранить конфиденциальную информацию,
например пароли, в варианте ConfigMap под названием Secret.
На этом мы заканчиваем обзор ключевых концепций Kubernetes. Теперь при
меним их на практике, развернув сервис приложения на этой платформе.
12.4.2. Развертывание сервиса Restaurant в Kubernetes
Как упоминалось ранее, для запуска сервиса в Kubernetes необходимо определить
его развертывание. Самый простой способ создания объекта Kubernetes, такого как
развертывание, состоит в написании файла в формате YAML. В листинге 12.4 пока
зан YAML-файл, описывающий развертывание для сервиса Restaurant. Оно должно
запустить две реплики (копии) pod-оболочки. Pod-оболочка содержит лишь один
контейнер. В определении контейнера указаны запускаемый образ Docker и другие
атрибуты, такие как значения переменных окружения. Переменные окружения кон
тейнера играют роль внешней конфигурации сервиса. Они считываются фреймворком
Spring Boot и становятся доступными в виде свойств в контексте приложения.
468 Глава 12 • Развертывание микросервисов
Листинг 12.4. Развертывание Kubernetes для ftgo-restaurant-service
apiVersion: extensions/vlbetal
kind: Deployment ◄----------
metadata:
name: ftgo-restaurant-service <
Указывает на то, что это
объект типа Deployment
_ _ _ _ _ | Название развертывания
spec:
replicas: 2 <
template:
metadata:
labels:
app: ftgo-restaurant-service <■
spec:
containers:
- name: ftgo-restaurant-service
image: msapatterns/ftgo-restaurant-service:latest
imagePullPolicy: Always
] Количество реплик pod-оболочки
Назначает каждой pod-оболочке
метку с названием арр
и значением ftgo-restaurant-service
Спецификация pod-оболочки, которая
определяет лишь один контейнер
ports:
- containerPort: 8080 <
Порт контейнера
name: httpport
env: ◄------
- name: JAVA_OPTS
value: "-Dsun.net.inetaddr.ttl=30"
Переменные окружения контейнера,
которые читает Spring Boot
name: SPRING_DATASOURCE_URL
value: jdbc:mysql://ftgo-mysql/eventuate
name: SPRING_DATASOURCE_USERNAME
valueFrom:
secretKeyRef:
name: ftgo-db-secret ◄—
key: username
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
Требующие особого отношения значения,
которые извлекаются из объекта Secret
под названием ftgo-db-secret
secretKeyRef:
name: ftgo-db-secret
key: password
- name: SPRING_DATASOURCE_DRIVER_CLASS_NAME
value: com.mysql.jdbc.Driver
- name: EVENTUATELOCAL_KAFKA_BOOTSTRAP_SERVERS
value: ftgo-kafka:9092
- name: EVENTUATELOCAL_ZOOKEEPER_CONNECTION_STRING
value: ftgo-zookeeper:2181
livenessProbe: ◄—
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 60
periodSeconds: 20
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 60
periodSeconds: 20
Конфигурируем Kubernetes, чтобы вызывать
конечную точку для проверки работоспособности
12.4. Развертывание приложения FTGO с помощью Kubernetes 469
Определение этого развертывания конфигурирует платформу Kubernetes гак,
чтобы она вызывала конечную точку для проверки работоспособности, принадле
жащую сервису Restaurant. Как говорилось в главе 11, эта конечная точка позволяет
Kubernetes определить состояние экземпляра сервиса. Kubernetes поддерживает
два вида проверок. Первая называется readinessProbe, она определяет, нужно ли
направлять трафик к экземпляру сервиса. В этом примере Kubernetes каждые 20 с
вызывает по HTTP конечную точку /actuator/health, но только после начальной
30-секундной задержки, которая дает сервису время на инициализацию. Если какое-
то количество последовательных проверок типа readinessProbe (по умолчанию одна)
заканчивается успешно, Kubernetes считает сервис готовым к работе, при получении
подряд определенного количества отказов (по умолчанию три) сервис считается
неготовым. Kubernetes направляет трафик к сервису только в том случае, если про
верка readinessProbe указывает на его готовность.
Вторая проверка называется livenessProbe. Она настраивается так же, как
и readinessProbe. Но вместо определения того, следует ли направлять трафик
к экземпляру сервиса, она подсказывает Kubernetes, нужно ли этот экземпляр
удалить и запустить заново. Если какое-то количество последовательных проверок
livenessProbe завершается неудачно, Kubernetes перезапускает сервис.
Написав YAML-файл, вы можете создать или обновить развертывание с помо
щью команды kubectl apply:
kubectl apply -f ftgo-restaurant-service/src/deployment/kubernetes/ftgo-
restaurant-service.yml
Эта команда делает запрос к API-серверу Kubernetes, результатом которого будет
создание развертывания и pod-оболочек.
Но перед этим вы должны создать объект Secret ftgo-db-secret. Это можно
сделать быстрым и не совсем безопасным способом:
kubectl create secret generic ftgo-db-secret \
--from-literal=username=mysqluser --from-literal=password=mysqlpw
Данная команда создает объект Secret с идентификатором и паролем пользо
вателя базы данных, указанными в командной строке. Другие, более безопасные
способы описаны в документации Kubernetes (kubernetes.io/docs/concepts/configuration/
secrety#creating-your-own-secrets).
Создание сервиса в Kubernetes
На этом этапе pod-оболочки уже запущены, и развертывание Kubernetes будет
стараться поддерживать их в рабочем состоянии. Но проблема в том, что IP-адреса
pod-оболочек были назначены динамически и по этой причине клиент не может ими
воспользоваться для выполнения HTTP-запросов. Решение, описанное в главе 3,
состоит в применении механизма обнаружения.
Обнаружение можно выполнять на клиентской стороне, установив реестр сер
висов, такой как Netflix OSS Eureka. К счастью, чтобы этого избежать, достаточно
определить сервис Kubernetes и использовать встроенный механизм обнаружения.
470 Глава 12 • Развертывание микросервисов
Сервис — это объект Kubernetes, который предоставляет клиентам одну или
несколько pod-оболочек со стабильной конечной точкой. Он имеет IP-адрес
и DNS-имя, которое на него ссылается. Сервис распределяет между pod-оболочками
трафик, приходящий на этот IP-адрес. В листинге 12.5 показан сервис Kubernetes
для Restaurantservice. Он направляет трафик из http://ftgo-restaurant-service:8080 к pod-
оболочкам, определенным в развертывании, представленном в листинге.
Листинг 12.5. Определение сервиса Kubernetes для ftgo-restaurant-service в формате YAML
apiVersion: vl
kind: Service
metadata:
name: ftgo-restaurant-service ◄-
spec:
Ports: Открытый порт
- port: 8080 ◄----- 1
targetPort: 8080 ◄-----
selector:
app: ftgo-restaurant-service
Название сервиса
и DNS-имя
Порт контейнера, к которому
направляется трафик
Выбирает контейнеры, к которым
будет направляться трафик
Ключевая часть определения сервиса — раздел selector, который выбирает
целевые pod-оболочки, то есть те, у которых есть метка арр со значением ftgo-
restaurant-service. Если внимательно присмотреться, можно увидеть, что у кон
тейнера, описанного в листинге 12.4, есть такая метка.
Написав YAML-файл, вы можете создать сервис с помощью следующей команды:
kubectl apply -f ftgo-restaurant-service-service.yml
Благодаря тому что мы создали сервис Kubernetes, любые клиенты Restau-
rantService, работающие внутри кластера Kubernetes, могут обращаться к его
интерфейсу REST API по адресу http://ftgo-restaurant‘Service:8080. Позже я покажу,
как обновлять запущенные сервисы, но сначала посмотрим, как сделать наш сервис
доступным из-за пределов кластера Kubernetes.
12.4.3. Развертывание API-шлюза
Сервис Kubernetes для Restaurantservice, показанный в листинге 12.5, доступен
только внутри кластера. Для самого сервиса это не проблема, но как насчет API-
шлюза? Его роль состоит в направлении внешнего трафика к внутренним компо
нентам. Поэтому он должен быть доступен извне. К счастью, Kubernetes поддер
живает и этот сценарий использования. Сервис, рассмотренный ранее, имеет тип
ClusterIP, применяемый по умолчанию, но в нашем распоряжении есть два других
типа: NodePort и LoadBalancer.
Сервисы типа NodePort доступны на общекластерном порте на всех рабочих узлах
кластера. Любой трафик, направленный на этот порт на любом из рабочих узлов
кластера, распределяется между внутренними pod-оболочками. Номер порта должен
12.4. Развертывание приложения FTGO с помощью Kubernetes 471
относиться к диапазону 30 000-32 767. Например, в листинге 12.6 показан сервис,
который направляет трафик на порт 30 000 сервиса Consumer.
Листинг 12.6. YAML-определение сервиса типа NodePort, который направляет трафик
на порт 30 000 сервиса Consumer
apiVersion: vl
kind: Service
metadata:
name: ftgo-api-gateway
spec:
type: NodePort ◄—
ports:
- nodePort: 30000 <
] Задает тип NodePort
port: 80
targetPort: 8080
selector:
Общекластерный порт
app: ftgo-api-gateway
API-шлюз доступен внутри кластера по адресу http://ftgo-api-gateway, для обраще
ния к нему извне используется URL http://<node-ip-address>:30000/, где node-ip-address —
это IP-адрес одного из рабочих узлов. Сконфигурировав сервис типа NodePort, вы
можете, к примеру, настроить AWS Elastic Load Balancer (ELB), чтобы распределять
внешний трафик между узлами. Ключевое преимущество этого подхода заключается
в том, что балансировщик ELB находится под вашим полным контролем и обеспе
чивает максимальную гибкость при настройке.
Однако сервисы типа NodePort — это не единственный вариант. Вы можете
использовать также тип LoadBalancer, который автоматически конфигурирует
балансировщик нагрузки для конкретной облачной платформы. В случае с AWS
это будет ELB. Одно из преимуществ сервисов этого типа состоит в том, что вам
больше не нужно настраивать собственный балансировщик. Но есть и обратная
сторона: несмотря на то что Kubernetes предоставляет несколько параметров для
настройки ELB, таких как SSL-сертификат, конфигурация балансировщика теряет
свою гибкость.
12.4.4. Развертывание без простоя
Представьте, что вы обновили сервис Restaurant и хотите развернуть эти изменения
в промышленной среде. Kubernetes превращает обновление запущенного сервиса
в простой процесс, состоящий из трех шагов.
1. Сборка и загрузка в реестр нового контейнера. Здесь используется описанный
ранее процесс. Разница лишь в том, что образ получит метку с другой версией,
например ftgo-restaurant-service:1.1.0.RELEASE.
2. Редактирование YAML-файла с развертыванием сервиса таким образом, чтобы
оно ссылалось на новый образ.
3. Обновление развертывания с помощью команды kubectl apply -f.
472 Глава 12 • Развертывание микросервисов
После этого платформа Kubernetes выполнит плавающее обновление pod-
оболочек. Она шаг за шагом создаст pod-оболочки версии 1.1.0.RELEASE и удалит
запущенные экземпляры версии 1.0.0.RELEASE. Замечательной особенностью
платформы Kubernetes является то, что она начинает удалять старые pod-оболочки
только тогда, когда их замены уже готовы к работе. Готовность определяется с по
мощью механизма проверки работоспособности readinessProbe, описанного ранее
в этом разделе. Благодаря этому у вас всегда будут pod-оболочки, готовые к обра
ботке запросов. В итоге, если запуск pod-оболочек пройдет успешно, развертывание
перейдет на новую версию.
Но что, если в результате какой-то проблемы pod-оболочки версии 1.1.0. RELEASE
не запустятся? Это может быть вызвано ошибкой в коде, такой как опечатка в имени
образа, или отсутствием переменной окружения для нового свойства конфигурации.
В этом случае развертывание застопорится. У вас останется два варианта: либо ис
править YAML-файл и повторить обновление с помощью команды kubectl apply -f,
либо откатить развертывание.
Развертывание хранит историю так называемых выкатываний (rollout). Каждый
раз, когда вы его обновляете, оно создает новое выкатывание. Благодаря этому вы
можете легко откатить развертывание до предыдущей версии, выполнив команду:
kubectl rollout undo deployment ftgo-restaurant-service
Это приведет к тому, что Kubernetes заменит pod-оболочки версии 1.1.0. RE LEASE
pod-оболочками версии 1.0.0.RELEASE.
Развертывания в Kubernetes позволяют доставлять сервисы без простоя. Но что
если ошибка проявится уже после того, как pod-оболочка запустится и начнет при
нимать промышленный трафик? В этом случае Kubernetes продолжит выкатывать
новые версии, что будет отражаться на все большем числе пользователей. И хотя
ваша система мониторинга, будем надеяться, обнаружит проблему и быстро откатит
развертывание, некоторые пользователи все же пострадают. Чтобы этого избежать
и сделать новую версию сервиса более надежной, необходимо отделить разверты
вание (запуск сервиса в промышленной среде) от выпуска сервиса, в результате
которого тот может начать обрабатывать промышленный трафик. Посмотрим, как
этого добиться с помощью сети сервисов.
12.4.5. Использование сети сервисов для отделения
развертывания от выпуска
Традиционно перед выкатыванием новая версия сервиса тестируется в предпро-
мышленных условиях. Пройдя этот этап, она выкатывается в промышленную среду
посредством плавающего обновления, заменяя собой старые экземпляры сервиса.
С одной стороны, вы уже могли убедиться в том, что Kubernetes максимально упро
щает выполнение плавающих обновлений. Но с другой — этот подход основан на
допущении, что новая версия сервиса, пройдя проверку в предпромышленных усло
виях, будет работать в промышленной среде. К сожалению, так происходит не всегда.
12.4. Развертывание приложения FTGO с помощью Kubernetes 473
Чаще всего предпромышленные условия не идентичны промышленным. Это свя
зано как минимум с тем, что промышленная среда, скорее всего, имеет куда больший
масштаб и обрабатывает намного больше трафика. К тому же на синхронизацию
двух сред затрачивается много времени. Из-за этих расхождений некоторые ошибки
могут проявиться только в реальных условиях. Но, даже если расхождений нет, вы
не можете гарантировать, что тестирование выявит все ошибки.
Выкатывание новых версий можно сделать намного более надежным, отделив
развертывание сервиса от его выпуска:
□ развертывание — выполнение в промышленной среде;
□ выпуск сервиса — открытие доступа к нему конечным пользователям.
Развертывание сервиса в промышленной среде состоит из следующих шагов.
1. Развертывание новой версии в промышленной среде без перенаправления к ней
запросов конечных пользователей.
2. Тестирование ее в реальных условиях.
3. Выпуск сервиса для небольшого количества пользователей.
4. Постепенный выпуск сервиса для все более широкой аудитории, пока он не ста
нет обрабатывать весь промышленный трафик.
5. Если на каком-либо этапе появится проблема, можно откатиться к старой версии.
Если же вы уверены в том, что все работает как следует, старую версию можно
удалить.
В идеале эти шаги следует выполнять в процессе полностью автоматизирован
ного развертывания, который тщательно следит за возникновением ошибок в све
жеразвернутом сервисе.
Раньше подобное разделение развертываний и выпусков было затруднительным,
так как для его реализации требовалось много усилий. Но при наличии сети сервисов
использовать этот стиль развертывания становится намного проще. Как говори
лось в главе 11, сеть сервисов — это сетевая инфраструктура, через которую сервис
общается с другими сервисами и внешними приложениями. Помимо того что она
берет на себя некоторые из обязанностей фреймворка микросервисного шасси, сеть
сервисов предоставляет балансирование нагрузки и маршрутизацию трафика на
основе правил, что позволяет безопасно запускать сразу несколько версий одного
и того же сервиса. Позже в этом разделе вы увидите, что тестовых пользователей
можно направлять к одной версии, а настоящих — к другой.
Как сообщалось в главе 11, вам доступно несколько реализаций сети сервисов.
В этом разделе я покажу, как использовать Istio — популярную сеть сервисов с от
крытым исходным кодом, созданную компаниями Google, IBM и Lyft. Вначале я сде
лаю краткий обзор этого проекта и некоторых из его многочисленных возможностей.
Затем покажу, как развернуть приложение с помощью Istio. После этого вы увидите,
как с помощью механизма маршрутизации этой сети наладить развертывание и вы
пуск обновлений сервиса.
474 Глава 12 • Развертывание микросервисов
Краткий обзор сети сервисов Istio
На официальном веб-сайте Istio описывается как «открытая платформа для объ-
единения, администрирования и защиты микросервисов» (https://istio.io). Это сетевой
слой, через который проходит весь трафик ваших сервисов. У Istio богатый набор
возможностей, которые делятся на четыре основные категории:
□ управление трафиком — включает в себя обнаружение сервисов, балансирование
нагрузки, правила маршрутизации и предохранители;
□ безопасность — безопасное межсервисное взаимодействие на основе протокола
защиты транспортного уровня (Transport Layer Security, TLS);
□ телеметрия — собирает сведения о сетевом трафике и реализует распределенную
трассировку;
□ соблюдение политики — обеспечивает соблюдение квот и лимитов на частоту
запросов.
В этом разделе мы сосредоточимся на возможностях Istio, относящихся к управ
лению трафиком.
Архитектура Istio (рис. 12.11) состоит из уровня управления и уровня данных.
Управляющий уровень реализует такие функции администрирования, как конфи
гурация уровня данных для маршрутизации трафика. Уровень данных состоит из
прокси-серверов Envoy, по одному для каждого экземпляра сервиса.
Два основных компонента управляющего уровня — Pilot и Mixer. Pilot извлекает
из внутренней инфраструктуры информацию о развернутых сервисах. Например,
работая в рамках Kubernetes, этот компонент получает сведения о сервисах и рабо
тоспособных pod-оболочках. Он настраивает прокси Envoy для перенаправления
трафика согласно заданным правилам маршрутизации. Mirer собирает телеметрию
из прокси Envoy и обеспечивает соблюдение политики.
Прокси Istio Envoy является модифицированной версией Envoy (www.envoyproxy.io).
Это высокопроизводительный прокси-сервер с поддержкой разнообразных протоко
лов, включая TCP, высокоуровневые и низкоуровневые протоколы, такие как HTTP
и HTTPS. Он также понимает протоколы MongoDB, Redis и DynamoDB. Кроме того,
Envoy поддерживает надежное межсервисное взаимодействие с такими функциями,
как предохранители, ограничение частоты запросов и автоматическое повторение
вызовов. Он может защитить коммуникации внутри приложения, задействуя TLS
для общения между прокси-серверами Envoy.
Istio использует Envoy в качестве подключаемого модуля (шаблон Sidecar) —
процесса или контейнера, который работает в связке с экземпляром сервиса и реа
лизует общую функциональность. В Kubernetes прокси Envoy запускается в виде
контейнера внутри pod-оболочки сервиса. В других средах, в которых нет понятия
pod-оболочки, Envoy работает в одном контейнере с сервисом. Прокси-сервер Envoy
пропускает через себя весь входящий и исходящий трафик сервиса и направляет
его согласно правилам маршрутизации, которые предоставляются управляющим
уровнем. Например, непосредственное взаимодействие типа «сервис сервис» при
обретает вид «сервис -> исходный Envoy -> конечный Envoy -> сервис».
12.4. Развертывание приложения FTGO с помощью Kubernetes 475
Рис. 12.11. Istio состоит из управляющего уровня, в число компонентов которого входят
Pilot и Mixer, и уровня данных, состоящего из прокси-серверов Envoy. Pilot извлекает
из внутренней инфраструктуры сведения о развернутых сервисах и конфигурирует уровень
данных. Mixer обеспечивает соблюдение политик, таких как квоты, собирает телеметрию
и передает ее серверам инфраструктуры мониторинга. Прокси-серверы Envoy маршрутизируют
входящий и исходящий трафик сервисов. У каждого экземпляра сервиса есть отдельный
прокси-сервер Envoy
476 Глава 12 • Развертывание микросервисов
Istio настраивается с помощью конфигурационных YAML-файлов в стиле
Kubernetes. У этой системы есть утилита командной строки istioctl, похожая на
kubectl. Она используется для создания, обновления и удаления правил и политик.
Работая с Istio в Kubernetes, можно задействовать также kubectl.
Посмотрим, как развернуть сервис с помощью Istio.
Развертывание сервиса с помощью Istio
Процесс развертывания сервиса в Istio выглядит довольно просто. Для каждого серви
са приложения необходимо определить объекты Service и Deployment. Пример таких
определений в контексте сервиса Consumer приведен в листинге 12.7. Они почти
полностью совпадают с определениями, которые я показывал ранее, за исключением
нескольких деталей. Istio предъявляет определенные требования к сервисам и pod-
оболочкам Kubernetes.
□ Порт сервиса Kubernetes должен использовать соглашение об именовании, при
нятое в Istio и имеющее вид <протокол>[-<суффикс>]. В качестве протокола можно
указать http, http2, grpc, mongo или redis. Если имя не указано, Istio считает, что
это порт TCP, и не применяет правила маршрутизации.
□ Pod-оболочка должна иметь метку арр, такую как арр: ftgo-consumer-service,
которая идентифицирует сервис. Это требуется для поддержки распределенной
трассировки в Istio.
□ Чтобы иметь возможность запускать сразу несколько версий сервиса, название
развертывания Kubernetes должно включать в себя версию, например, ftgo-
consumerservice-vl, ftgo-consumer-service-v2 и т. д. У pod-оболочки развер
тывания должна быть метка version, такая как version: vl. Это позволяет Istio
направлять трафик к конкретной версии сервиса.
Листинг 12.7. Развертывание сервиса Consumer с помощью Istio
apiVersion: vl
kind: Service
metadata:
name: ftgo-consumer-service
spec:
Ports: I Именованный порт
- name: http ◄--------- 1
port: 8080
targetPort: 8080
selector:
app: ftgo-consumer-service
apiVersion: extensions/vlbetal
kind: Deployment
metadata^ | версионное развертывание
name: ftgo-consumer-service-v2 ◄-------- 1 r r r
spec:
replicas: 1
template:
metadata:
12.4. Развертывание приложения FTGO с помощью Kubernetes 477
labels:
арр: ftgo-consumer-service <■
version: v2
Рекомендуемые метки
spec:
containers:
- image: image: ftgo-consumer-service:v2 <
] Версия образа
Вам, наверное, уже интересно, как запустить прокси-контейнер Envoy в pod-
оболочке сервиса. К счастью, Istio делает эту задачу на удивление простой, автома
тизируя добавление записи о прокси Envoy в определение pod-оболочки. Это можно
сделать двумя способами. Первый способ состоит в ручном внедрении контейнера
с последующим выполнением команды istioctl kube-inject:
istioctl kube-inject -f ftgo-consumer-service/src/deployment/kubernetes/ftgo-
consumer-service.yml | kubectl apply -f -
Эта команда считывает YAML-файл Kubernetes и возвращает модифициро
ванную конфигурацию, содержащую прокси Envoy. Затем полученный результат
передается по каналу команде kubectl apply.
Второй способ подключения контейнера Envoy к pod-оболочке заключается
в автоматическом внедрении. Когда эта функция включена, развертывание сервиса
производится с помощью команды kubectl apply. Kubernetes автоматически вызовет
Istio для включения записи о прокси Envoy в определение pod-оболочки.
Если открыть определение pod-оболочки, можно увидеть, что оно не ограничи
вается контейнером вашего сервиса:
$ kubectl describe ро ftgo-consumer-service-7db65b6f97-q9jpr
Name: ftgo-consumer-service-7db65b6f97-q9jpr
Namespace: default
Инициализирует
Init Containers: pod-оболочку
istio-init: ◄---------
Image: docker.io/istio/proxy_init:0.8.0
_ _ _ _ _ | Контейнер сервиса
Image: msapatterns/ftgo-consumer-service:latest
Containers:
ftgo-consumer-service:
istio-proxy: I Контейнер Envoy
Image: docker.io/istio/proxyv2:0.8.0 ◄--------- 1
Итак, мы развернули сервис. Теперь поговорим о том, как описать правила
маршрутизации.
Создание правил маршрутизации
для направления трафика к версии vl
Представьте, что вы развернули объект ftgo-consumer-service-v2. В случае от
сутствия правил маршрутизации Istio распределяет запросы между всеми вер
сиями сервиса. Таким образом, нагрузка будет ложиться на версии 1 и 2 сервиса
478 Глава 12 • Развертывание микросервисов
ftgo-consumer-service, из-за чего теряется весь смысл использования Istio. Чтобы
безопасно выкатить новую версию, вы должны определить правило маршрутизации,
которое направляет весь трафик к текущей версии vl.
На рис. 12.12 показано правило маршрутизации для сервиса Consumer, которое
направляет весь трафик к vl. Оно состоит из двух объектов Istio — VirtualService
и DestinationRule.
VirtualService определяет маршрутизацию запросов к одному или нескольким
сетевым узлам. В этом примере указаны маршруты для одного узла — ftgo-consumer-
service. Далее приводится определение VirtualService для сервиса Consumer:
apiVersion: networking.istio.io/vlalpha3
kind: VirtualService
metadata:
name: ftgo-consumer-service
_ _ _ | Применяется к сервису Consumer
spec:
hosts:
- ftgo-consumer-service
http:
- route:
- destination:
host: ftgo-consumer-service <■
subset: vl « ( подмножествоvl
] Направляет к сервису Consumer
Этот объект направляет все запросы к подмножеству vl pod-оболочек сервиса
Consumer. Позже я покажу более сложный пример с маршрутизацией на основе
HTTP-запросов и балансированием нагрузки между несколькими взвешенными
адресатами.
Помимо VirtualService, вы должны определить объект DestinationRule, который
описывает одно или несколько подмножеств pod-оболочек. Подмножеством обычно
служит версия сервиса. Также DestinationRule может определить политики управ
ления трафиком, такие как алгоритм балансирования нагрузки. Далее приведен
пример этого объекта для сервиса Consumer:
apiVersion: networking.istio.io/vlalpha3
kind: DestinationRule
metadata:
name: ftgo-consumer-service
spec:
host: ftgo-consumer-service
subsets:
- name: vl
| Имя подмножества
labels:
version: vl
- name: v2
labels:
version: v2
Селектор для pod-оболочек
подмножества
DestinationRule определяет два подмножества pod-оболочек: vl и v2. Подмноже
ство vl охватывает pod-оболочки с меткой version: vl. Pod-оболочки, помеченные
как version: v2, входят в подмножество v2.
После определения этих правил Istio будет направлять трафик только к pod-
оболочкам с меткой version: vl, благодаря чему мы сможем безопасно развернуть vl.
12.4. Развертывание приложения FTGO с помощью Kubernetes 479
Р
и
с.
1
2.
1
2.
П
ра
ви
ло
м
ар
ш
ру
ти
за
ци
и
дл
я
се
рв
ис
а
Co
ns
um
er
, к
от
ор
ое
н
ап
ра
вл
яе
т
ве
сь
т
ра
ф
ик
к
p
od
-о
бо
ло
чк
ам
в
ер
си
и
vl
. О
но
с
ос
то
ит
из
о
бъ
ек
то
в
Vi
rt
ua
lS
er
vi
ce
и
D
es
tin
at
io
nR
ul
e,
п
ер
вы
й
на
пр
ав
ля
ет
т
ра
ф
ик
к
п
од
м
но
ж
ес
тв
у
vl
, а
в
то
ро
й
вк
лю
ча
ет
в
п
од
м
но
ж
ес
тв
о
v2
p
od
-о
бо
ло
чк
и
с
м
ет
ко
й
ve
rs
io
n:
v
l.
П
ос
ле
о
пр
ед
ел
ен
ия
э
то
го
п
ра
ви
ла
в
ы
м
ож
ет
е
бе
зо
па
сн
о
ра
зв
ер
ну
ть
н
ов
ую
в
ер
си
ю
т
ак
,
чт
об
ы
и
зн
ач
ал
ьн
о
к
не
й
не
н
ап
ра
вл
ял
ся
н
ик
ак
ой
т
ра
ф
ик
480 Глава 12 • Развертывание микросервисов
Развертывание версии v2 сервиса Consumer
Далее представлен фрагмент развертывания версии 2 для сервиса Consumer:
apiVersion: extensions/vlbetal
kind: Deployment
metadata:
name: ftgo-consumer-service-v2
_ _ _ | Версия 2
spec:
replicas: 1
template:
metadata:
labels:
app: ftgo-consumer-service
version: v2 4—
Pod-оболочка
сметкой версии
Это развертывание называется ftgo-consumer-service-v2. Оно маркирует свои
pod-оболочки version: v2. После его создания мы получим две рабочие версии сер
виса ftgo-consumer-service. Но благодаря правилам маршрутизации Istio не ста
нет направлять трафик к v2. Это позволит вам направить к этой версии тестовые
запросы.
Направление тестовых запросов к версии v2
Следующий шаг после развертывания новой версии сервиса — ее тестирование.
Предположим, что тестовые запросы от тестовых пользователей содержат заголовок
testuser. Мы можем сделать так, чтобы сервис VirtualService из состава ftgo-
consumer-service направлял их к экземплярам версии v2. Для этого нужно внести
такое изменение:
apiVersion: networking.istio.io/vlalpha3
kind: VirtualService
metadata:
name: ftgo-consumer-service
spec:
hosts:
- ftgo-consumer-service
http:
- match:
- headers:
testuser:
regex: "A.+$" <
route:
Ищет непустой заголовок testuser
- destination:
host: ftgo-consumer-service
subset: v2 4—
- route:
- destination:
host: ftgo-consumer-service
subset: vl 4—
Направляет тестовых
пользователей к v2
Направляет
всех остальных к v1
12.5. Бессерверное развертывание сервисов 481
Помимо исходного маршрута, указанного по умолчанию, VirtualService содер
жит правило маршрутизации, которое направляет запросы с заголовком test user
к подмножеству v2. После обновления этих правил можно протестировать сервис
Consumer. Затем, убедившись в том, что версия v2 готова к работе, вы можете напра
вить к ней часть промышленного трафика. Посмотрим, как это сделать.
Направление промышленного трафика к версии v2
Следующим шагом после тестирования свежеразвернутого сервиса будет направление
к нему промышленного трафика. Начинать лучше с небольших объемов. Здесь, напри
мер, показано правило, которое направляет 95 % запросов к vl, а 5 % — к v2:
apiVersion: networking.istio.io/vlalpha3
kind: VirtualService
metadata:
name: ftgo-consumer-service
spec:
hosts:
- ftgo-consumer-service
http:
- route:
- destination:
host: ftgo-consumer-service
subset: vl
weight: 95
- destination:
host: ftgo-consumer-service
subset: v2
weight: 5
Убедившись в том, что сервис способен обрабатывать промышленный трафик,
увеличивайте количество запросов, направленных к pod-оболочкам версии 2, до
тех пор, пока оно не достигнет 100 %. В итоге Istio перестанет слать запросы pod-
оболочкам версии 1. Можете дать им возможность поработать некоторое время,
прежде чем удалять развертывание vl.
Позволяя легко разделять развертывание и выпуск сервисов, Istio делает вы
катывание новых версий намного более надежным. Но это лишь небольшая часть
возможностей, которыми обладает Istio. На момент написания этих строк текущей
версией проекта была 0.8. Мне очень нравится наблюдать за тем, как эта и другие
сети сервисов становятся стандартным элементом промышленной среды.
12.5. Бессерверное развертывание сервисов
Пакеты, рассчитанные на определенные языки (см. раздел 12.1), виртуальные маши
ны (см. раздел 12.2) и контейнеры (см. раздел 12.3) довольно сильно различаются,
но у всех них есть и общее. Во-первых, все эти шаблоны развертывания должны
заранее выделять определенные вычислительные ресурсы — физические серверы,
482 Глава 12 • Развертывание микросервисов
виртуальные машины или контейнеры. Некоторые платформы развертывания
поддерживают автомасштабирование, динамически регулируя количество ВМ или
контейнеров в зависимости от нагрузки. Тем не менее вам постоянно нужно платить
за какие-то ресурсы, даже если они простаивают.
Еще одной общей чертой является то, что ответственность за системное адми
нистрирование ложится на вас. С каким бы сервером вы ни работали, его опера
ционную систему нужно обновлять. Если это физические серверы, их необходимо
распределять по стойкам. Вы также отвечаете за обслуживание среды выполнения
языка программирования. Это то, что компания Amazon называет неразделяемой
рутинной работой. С первых дней возникновения компьютерной индустрии систем
ное администрирование было тем, без чего нельзя обойтись. Но, как оказывается,
у этого правила есть исключение — бессерверные платформы.
12.5.1. Обзор бессерверного развертывания
с помощью AWS Lambda
На конференции AWS Reinvent 2014 Вернер Вогельс (Werner Vogels), техниче
ский директор Amazon, в ходе представления AWS Lambda произнес потрясающую
фразу: «На пересечении функций, событий и данных происходит магия». Как мож
но догадаться из этих слов, платформа AWS Lambda изначально предназначалась
для развертывания сервисов с событийной моделью, а «магической» ее делает то,
что это пример технологии бессерверного развертывания.
AWS Lambda поддерживает Java, NodeJS, С#, GoLang и Python. Лямбда-функ
ция — это сервис, лишенный состояния. Для обработки запросов она обычно об
ращается к сервисам AWS. Например, лямбда-функция, которая срабатывает при
12.5. Бессерверное развертывание сервисов 483
загрузке изображения в облако S3, вставляет элемент в таблицу IMAGES DynamoDB
и публикует сообщение в Kinesis, чтобы инициировать обработку этого изображения.
Лямбда-функция также может вызывать сторонние веб-сервисы.
Чтобы развернуть сервис, нужно упаковать его в файл ZIP или JAR, загру
зить в AWS Lambda и указать имя функции, которая будет вызываться для об
работки запроса (который еще называют событием). AWS Lambda автоматически
следит за тем, чтобы экземпляров вашего микросервиса было достаточно для об
работки входящих запросов. Вы платите за каждый запрос с учетом потраченного
времени и потребленной памяти. Конечно, дьявол кроется в деталях, и позже вы
увидите, что AWS Lambda имеет ограничения. Но само то, что ни вы, как разра
ботчик, ни кто-нибудь другой в вашей организации не должны волноваться о ка
ких-либо аспектах серверов, виртуальных машин или контейнеров, чрезвычайно
интересно.
12.5.2. Написание лямбда-функции
В отличие от предыдущих трех шаблонов лямбда-функции требуют использования
особой модели разработки. Их код и формат упаковывания зависят от языка про
граммирования. В Java лямбда-функция представляет собой класс, реализующий
обобщенный интерфейс RequestHandler, который определен в основной библиотеке
AWS Lambda Java (листинг 12.8). Этот интерфейс принимает параметры двух типов:
I — тип ввода и 0 — тип вывода. Они зависят от того, какого рода запросы обраба
тывает лямбда-функция.
Листинг 12,8. В Java лямбда-функция является классом, который реализует
интерфейс RequestHandler
public interface RequestHandler<I, 0> {
public 0 handleRequest(I input, Context context);
}
Интерфейс RequestHandler определяет единственный метод — handleRequestQ.
У него есть два параметра — входящий объект и контекст, который предоставляет
доступ к среде выполнения Lambda, например к ID запроса. В качестве результа
та возвращается исходящий объект. У лямбда-функций, которые обрабатывают
HTTP-запросы, проходящие через API-шлюз AWS, в качестве I и 0 используются
типы APIGatewayProxyRequestEvent и APIGatewayProxyResponseEvent соответствен
но. Вскоре вы увидите, что функции обработки очень напоминают старые добрые
сервлеты из Java ЕЕ.
484 Глава 12 • Развертывание микросервисов
В Java лямбда-функции упаковываются в файлы ZIP или JAR. Последние име
ют формат uber JAR («толстый JAR») и создаются такими инструментами, как
дополнение Maven Shade. ZIP-файлы хранят классы в корневом каталоге, a JAR-
зависимости — в папке lib. Позже я продемонстрирую создание ZIP-файлов в про
екте Gradle. Но сначала рассмотрим разные способы вызова лямбда-функций.
12.5.3. Вызов лямбда-функций
Лямбда-функцию можно вызвать четырьмя способами:
□ с помощью НТТР-запросов;
□ посредством событий, генерируемых сервисами AWS;
□ как запланированные вызовы;
□ напрямую с помощью API-вызовов.
Обработка НТТР-запросов
Вы можете сконфигурировать API-шлюз AWS таким образом, чтобы он направлял
HTTP-запросы к вашей лямбда-функции. Она будет доступна по протоколу HTTPS
в виде конечной точки. API-шлюз играет роль HTTP-прокси, он передает лямбда-
функции объект внутри HTTP-запроса и ожидает получения от нее НТТР-ответа.
Использование API-шлюза в сочетании с AWS Lambda позволяет, к примеру, раз
вертывать RESTful-сервисы в виде лямбда-функций.
Обработка событий, сгенерированных сервисами AWS
Лямбда-функцию можно сконфигурировать для обработки событий, генерируемых
сервисом AWS. Примеры событий, которые могут привести к срабатыванию лямб
да-функции:
□ в бакете S3 создан объект;
□ в таблице DynamoDB создан, обновлен или удален элемент;
□ в потоке Kinesis появилось сообщение, доступное для чтения;
□ с помощью Simple Email Service получено электронное письмо.
Благодаря такой интеграции с сервисами AWS лямбда-функции широко при
меняются.
Объявление запланированных лямбда-функций
Для вызова лямбда-функции можно также использовать механизм планирования
Linux в стиле cron. Вы можете сконфигурировать ее для периодических вызовов,
например, раз в минуту, три часа или семь дней. Для этого также предусмотрены
выражения в формате cron, которые определяют, когда платформа AWS должна
12.5. Бессерверное развертывание сервисов 485
вызвать вашу лямбда-функцию. Эти выражения чрезвычайно гибки. Например, вы
можете сделать так, чтобы лямбда-функция вызывалась каждый день с понедельника
по пятницу в 14:15.
Вызов лямбда-функций с помощью запросов веб-сервисов
Четвертый способ вызова лямбда-функций заключается в использовании веб
сервисов вашего приложения. Веб-сервис указывает в своем запросе имя лямбда-
функции и данные входящего события. Ваше приложение может делать синхронные
и асинхронные вызовы. В первом случае ответ лямбда-функции будет содержаться
в HTTP-ответе веб-сервиса. Если же вызов сделан асинхронно, ответ веб-сервиса
сигнализирует о том, был ли успешным запуск лямбда-функции.
12.5.4. Преимущества использования лямбда-функций
Развертывание сервисов в виде лямбда-функций имеет несколько преимуществ.
□ Интеграция со многими сервисами A WS. Вы можете с невероятной легкостью
писать лямбда-функции, которые потребляют события, сгенерированные такими
сервисами AWS, как DynamoDB и Kinesis, и обрабатывают HTTP-запросы через
API-шлюз AWS.
□ Избавление от многих задач системного администрирования. Вы больше не от
вечаете за низкоуровневое системное администрирование. У вас нет операцион
ных систем и сред выполнения, которые нужно обновлять. Благодаря этому вы
можете сосредоточиться на развертывании своего приложения.
□ Эластичность. AWS Lambda запускает экземпляры вашего приложения в ко
личестве, которого достаточно, чтобы справиться с нагрузкой. Вам не нужно
предсказывать необходимую пропускную способность или волноваться о недо
статочном или чрезмерном выделении ВМ или контейнеров.
□ Тарифы, основанные на потреблении. В отличие от типичных облаков IaaS, в ко
торых вы платите за каждую минуту или час работы ваших ВМ или контейнеров
(даже когда они простаивают), AWS Lambda берет плату только за ресурсы, по
траченные на обработку каждого запроса.
12.5.5. Недостатки использования лямбда-функций
Как видите, AWS Lambda — чрезвычайно удобный способ развертывания сервисов.
Но эта технология не лишена некоторых существенных недостатков и ограничений.
□ Периодически возникает высокая латентность. Поскольку в AWS Lambda ваш
код выполняется динамически, некоторые запросы будут демонстрировать
высокую латентность. Это связано с тем, что AWS нужно время на создание
экземпляра приложения и его запуск. Особенно остра эта проблема в сервисах,
написанных на Java, поскольку они обычно запускаются как минимум несколько
486 Глава 12 • Развертывание микросервисов
секунд. Например, лямбда-функция, представленная в следующем разделе, стар
тует довольно медленно. В связи с этим AWS Lambda может оказаться не лучшим
выбором для сервисов, требующих низкой латентности.
□ Ограниченная модель программирования, основанная на событиях/запросах.
Технология AWS Lambda не предназначена для развертывания длительное
время работающих сервисов, которые, к примеру, принимают сообщения от
стороннего брокера.
Из-за этих недостатков и ограничений AWS Lambda не всегда является хорошим
выбором. И прежде, чем рассматривать альтернативы, я советую проверить, совме
стимо ли бессерверное развертывание с требованиями вашего сервиса.
12.6. Развертывание RESTfu 1-сервиса
с помощью AWS Lambda и AWS Gateway
Давайте посмотрим, как развернуть сервис Restaurant с помощью AWS Lambda.
У этого сервиса есть REST API для создания и управления ресторанами. Он не ис
пользует долгоживущих соединений с Apache Kafka, что делает его хорошим канди
датом для запуска в AWS Lambda. Процесс его развертывания показан на рис. 12.13.
Сервис состоит из нескольких лямбда-функций, по одной для каждой конечной точки
REST. За направление запросов к этим функциям отвечает API-шлюз AWS.
Рис. 12.13. Развертывание сервиса Restaurant в виде функций AWS Lambda. API-шлюз AWS
направляет HTTP-запросы к лямбда-функциям, которые реализованы классами-обработчиками,
определенными в Restaurantservice
12.6. Развертывание RESTful-сервиса с помощью AWS Lambda и AWS Gateway 487
Каждая лямбда-функция содержит класс для обработки запросов. Функция
ftgo-create-restaurant вызывает класс CreateRestaurantRequestHandler, a ftgo-
find-restaurant — класс FindRestaurantRequestHandler. Поскольку эти классы
реализуют тесно связанные аспекты одного и того же сервиса, они упаковываются
в один ZIP-файл restaurant-service-aws-lambda.zip. Рассмотрим архитектуру этого
сервиса, включая его классы-обработчики.
12.6.1. Архитектура сервиса Restaurant
на основе AWS Lambda
Архитектура, представленная на рис. 12.14, довольно сильно напоминает тради
ционный сервис. Основное отличие в том, что вместо контроллеров Spring MVC
используются классы для обработки запросов из AWS Lambda. Остальная бизнес-
логика осталась неизменной.
Рис. 12.14. Архитектура сервиса Restaurant, основанного на AWS Lambda. Уровень представления
состоит из классов-обработчиков, реализующих лямбда-функции. Они обращаются к уровню
бизнес-логики, который написан в традиционном стиле и содержит класс сервиса, сущность
и репозиторий
Сервис состоит из уровня представления, в который входят классы-обработчики,
вызываемые платформой AWS Lambda для обработки НТТР-запросов, и традицион
ного уровня бизнес-логики. Последний содержит JPA-сущность Restaurantservice
и слой абстракции для базы данных RestaurantRepository.
Рассмотрим класс FindRestaurantRequestHandler.
Класс FindRestaurantRequestHandler
Класс FindRestaurantRequestHandler реализует конечную точку GET /restaurant/
(restaurantld). Этот и другие классы-обработчики являются листьями иерархии
классов (рис. 12.15). Корнем иерархии служит класс RequestHandler, входящий
488 Глава 12 • Развертывание микросервисов
в состав AWS SDK. Его абстрактные классы обрабатывают ошибки и внедряют
зависимости.
Рис. 12.15. Иерархия классов для обработки запросов. Абстрактные родительские классы
реализуют внедрение зависимостей и обработку ошибок
AbstractHttpHandler — это базовый абстрактный класс для обработчиков НТТР-
запросов. Он перехватывает необработанные исключения, сгенерированные во
время обработки запроса, и возвращает ответ вида 500 internal server error. Класс
AbstractAutowiringHttpRequestHandler реализует внедрение зависимостей для об
работчиков запросов. Я опишу эти абстрактные родительские классы чуть позже,
а сначала исследую код FindRestaurantRequestHandler.
Код класса FindRestaurantRequestHandler показан в листинге 12.9. Он со
держит метод handleHttpRequest(), который принимает в качестве параметра
объект APIGatewayProxyRequestEvent, представляющий HTTP-запрос. Он вызы
вает Restaurantservice, чтобы найти ресторан, и возвращает APIGatewayProxyRes-
ponseEvent с описанием НТТР-ответа.
Как видите, это сильно напоминает сервлет, только вместо метода service(), ко
торый принимает HttpServletRequest и возвращает HttpServletResponse, этот класс
использует метод handleHttpRequest(), принимающий APIGatewayProxyRequestEvent
и возвращающий APIGatewayProxyResponseEvent.
Теперь посмотрим на его родительский класс, который реализует внедрение за
висимостей.
12.6. Развертывание RESTful-сервиса с помощью AWS Lambda и AWS Gateway 489
Листинг 12.9. Класс-обработчик для GET /restaurant/{restaurantld}
public class FindRestaurantRequestHandler
extends AbstractAutowiringHttpRequestHandler {
@Autowired
private Restaurantservice restaurantservice;
^Override
protected Class<?> getApplicationContextClass() {
return CreateRestaurantRequestHandler.class;
}
Конфигурационный класс
из состава Spring, который будет
играть роль контекста приложения
^Override
protected APIGatewayProxyResponseEvent
handleHttpRequest(APIGatewayProxyRequestEvent request, Context context) {
long restaurantld;
try {
restaurantld = Long.parseLong(request.getPathParameters()
.get(”restaurantld"));
} catch (NumberFormatException e) { Если параметр restaurantld
return makeBadRequestResponse(context); ◄------ отсутствует или недействителен,
возвращает 400 — bad request response}
Optional<Restaurant> possibleRestaurant =
restaurantservice.findByld(restaurantld);
return possibleRestaurant ◄--------
Возвращает либо ресторан,
либо 404 not found
.map(this::makeGetRestaurantResponse)
orElseGet(() -> makeRestaurantNotFoundResponse(context,
restaurantld));
}
private APIGatewayProxyResponseEvent makeBadRequestResponse(Context context) {
}
private APIGatewayProxyResponseEvent
makeRestaurantNotFoundResponse(Context context, long restaurantld) { ... }
private APIGatewayProxyResponseEvent
makeGetRestaurantResponse(Restaurant restaurant) { ... }
}
Внедрение зависимостей с помощью класса
AbstractAutowiringHttpRequestHandler
Функции AWS Lambda не являются ни веб-приложениями, ни программами с мето
дом main(). Однако было бы досадно, если бы мы не могли воспользоваться возмож
ностями Spring Boot, к которым так привыкли. Класс AbstractAutowiringHttpRe-
questHandler, представленный в листинге 12.10, реализует внедрение зависимостей
490 Глава 12 • Развертывание микросервисов
для обработчиков запросов. Он создает контекст Applicationcontext с помощью
SpringApplication. run() и автоматически пробрасывает зависимости еще до обра
ботки первого запроса. Дочерние классы, такие как FindRestaurantRequestHandler,
должны реализовывать метод getApplicationContextClass().
Листинг 12.10. Абстрактный класс RequestHandler, реализующий внедрение зависимостей
public abstract class AbstractAutowiringHttpRequestHandler
extends AbstractHttpHandler {
private static ConfigurableApplicationContext ctx;
private ReentrantReadWriteLock ctxLock = new ReentrantReadWriteLock();
private boolean autowired = false;
Один раз создает контекст
приложения Spring Boot
protected synchronized Applicationcontext getAppCtx() {
ctxLock.writeLock().lock();
try {
if (ctx «« null) {
ctx = SpringApplication.run(getApplicationContextClass());
}
return ctx;
} finally {
ctxLock.writeLock().unlock();
}
}
Прежде чем обрабатывать первый запрос,
внедряет зависимости в обработчик,
используя автопробрасывание^Override
protected void
beforeHandling(APIGatewayProxyRequestEvent request, Context context) {
super.beforeHandling(request, context);
if (Iautowired) {
getAppCtx().getAutowireCapableBeanFactory().autowireBean(this); ◄------------
autowired ■ true;
}
)
Возвращает класс (^Configuration,
с помощью которого создается Applicationcontext
protected abstract Class<?> getApplicationContextClass();
}
Этот класс переопределяет метод beforeHandling() из AbstractHttpHandler.
Перед обработкой первого запроса его метод beforeHandling() внедряет зависимо
сти, используя автоматическое пробрасывание.
Класс AbstractHttpHandler
Обработчики запросов для сервиса Restaurant, по сути, наследуют класс Ab
stractHttpHandler, показанный в листинге 12.11. Этот класс реализует интерфейсы
RequestHandler<APIGatewayProxyRequestEvent и APIGatewayProxyResponseEvent >.
Его основные обязанности — перехват исключений, возникающих во время обра
ботки запросов, и возвращение кода ошибки 500.
12.6. Развертывание RESTful-сервиса с помощью AWS Lambda и AWS Gateway 491
Листинг 12.11. Абстрактный класс RequestHandler, который перехватывает исключения
и возвращает HTTP-ответ с кодом 500
public abstract class AbstractHttpHandler implements
RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
private Logger log = LoggerFactory.getLogger(this.getClass());
^Override
public APIGatewayProxyResponseEvent handleRequest(
APIGatewayProxyRequestEvent input, Context context) {
log.debugC’Got request: {}", input);
try {
beforeHandling(input, context);
return handleHttpRequest(input, context);
} catch (Exception e) {
log.error("Error handling request id: {}", context.getAwsRequestId(), e);
return buildErrorResponse(new AwsLambdaError(
"Internal Server Error",
"500",
context.getAwsRequestld(),
"Error handling request: " + context.getAwsRequestld() + " "
+ input.toString()));
}
}
protected void beforeHandling(APIGatewayProxyRequestEvent request,
Context context) {
// ничего не делать
}
protected abstract APIGatewayProxyResponseEvent handleHttpRequest(
APIGatewayProxyRequestEvent request, Context context);
}
12.6.2. Упаковывание сервиса в виде ZIP-файла
Прежде чем развертывать сервис, мы должны упаковать его в ZIP-файл. Легко сде
лать это с помощью следующего задания для Gradle:
task buildZip(type: Zip) {
from compilelava
from processResources
into('lib’) {
from configurations.runtime
}
}
Это задание собирает архив ZIP с классами и ресурсами на верхнем уровне
и JAR-зависимостями в каталоге lib.
Имея ZIP-файл, мы можем приступить к развертыванию лямбда-функции.
492 Глава 12 • Развертывание микросервисов
12.6.3. Развертывание лямбда-функций с помощью
бессерверного фреймворка
Развертывание лямбда-функций и настройка API-шлюза могут оказаться довольно
утомительными, если ограничиваться лишь инструментами, входящими в состав
AWS. К счастью, этот процесс можно существенно упростить, если воспользоваться
проектом с открытым исходным кодом под названием Serverless. Вам достаточно
написать простой файл serverless.yml со списком своих лямбда-функций и их
конечных точек RESTful, a Serverless автоматически их развернет и создаст, а также
сконфигурирует API-шлюз, чтобы тот направлял к ним запросы.
В листинге 12.12 показан фрагмент файла serverless.yml, который развертывает
сервис Restaurant в виде лямбда-функций.
Листинг 12.12. serverless.yml развертывает сервис Restaurant
service: ftgo-application-lambda
provider:
name: aws <
Говорит Serverless,
что развертывать нужно в AWS
runtime: java8
timeout: 35
region; ${env:AWS_REGION}
stage: dev
environment: <
Предоставляет сервису
внешнюю конфигурацию
в виде переменных окружения
SPRING_DATASOURCE_DRIVER_CLASS_NAME: com.mysql.jdbe.Driver
SPRING_DATASOURCE_URL: ...
SPRING_DATASOURCE_USERNAME: ...
SPRING_DATASOURCE_PASSWORD: ...
package:
ZIP-файл
с лямбда-функциями
artifact: ftgo-restaurant-service-aws-lambda/build/distributions/
ftgo-restaurant-service-aws-lambda.zip
functions: ◄---------
create-restaurant:
Определения лямбда-функций,
состоящие из функций
обработки и конечных точек
handler: net.chrisrichardson.ftgo.restaurantservice.lambda
.CreateRestaurantRequestHandler
events:
- http:
path: restaurants
method: post
find-restaurant:
handler: net.chrisrichardson.ftgo.restaurantservice.lambda
.FindRestaurantRequestHandler
events:
- http:
path: restaurants/{restaurantld}
method: get
Резюме 493
Вслед за этим можно воспользоваться командой serverless deploy, которая
считывает файл serverless.yml, развертывает лямбда-функции и конфигурирует
API-шлюз AWS. После непродолжительного ожидания ваш сервис станет до
ступен через URL-адрес конечной точки API-шлюза. Количества экземпляров
лямбда-функций сервиса Restaurant будет достаточно для того, чтобы справиться
с нагрузкой. В случае изменения кода вы легко обновите свои лямбда-функции,
пересобрав ZIP-файл и заново выполнив команду serverless deploy. И все это без
каких-либо серверов!
Инфраструктура развивается впечатляющими темпами. Не так давно мы вруч
ную развертывали приложения на физических серверах. Сегодня же высокоавтома
тизированные публичные облака предоставляют целый ряд вариантов виртуального
развертывания. Вы можете запустить свои сервисы в виде виртуальных машин или,
что еще лучше, упаковать их в контейнеры и развернуть с помощью многофункцио
нальных фреймворков оркестрации Docker, таких как Kubernetes. В некоторых слу
чаях сервисы можно развернуть в виде легковесных временных лямбда-функций,
полностью забыв об инфраструктуре.
Резюме
□ Вы должны выбрать наиболее легковесный шаблон развертывания, который
удовлетворяет требованиям вашего приложения. Варианты следует рассма
тривать в таком порядке: бессерверные платформы, контейнеры, виртуальные
машины и пакеты, рассчитанные на определенные языки.
□ Из-за периодически возникающей высокой латентности и программной модели
на основе событий/запросов бессерверное развертывание подходит не для всех
сервисов. Но в подходящих сценариях оно становится очень достойным реше
нием, которое позволяет забыть об администрировании операционных систем
и сред выполнения. Оно также поддерживает эластичное выделение ресурсов
и тарификацию отдельных запросов.
□ Контейнеры Docker — это легковесная технология виртуализации на уровне
ОС. По сравнению с бессерверным развертыванием они обеспечивают большую
гибкость и более предсказуемы в отношении латентности. Для работы с ними
лучше всего использовать фреймворк оркестрации Docker наподобие Kubernetes,
который управляет контейнерами в кластере серверов. Недостаток контейнеров
заключается в том, что вам придется заниматься администрированием операци
онных систем и сред выполнения, а также, вероятно, фреймворка оркестрации
Docker и виртуальных машин, в которых он выполняется.
□ Третий вариант — развертывание сервиса в виде виртуальной машины. С одной
стороны, это тяжеловесное решение, поэтому по сравнению со вторым вари
антом оно будет более медленным и, скорее всего, ресурсоемким. С другой сто
роны, современные облака, такие как Amazon ЕС2, высокоавтоматизированы
494 Глава 12 • Развертывание микросервисов
и предоставляют богатый набор возможностей. Поэтому иногда проще развер
нуть небольшое приложение с помощью виртуальной машины, чем настраивать
фреймворк оркестрации Docker.
□ Обычно стоит избегать развертывания сервисов в виде пакетов, предназначен
ных для конкретных языков. Исключение представляют собой случаи, когда
вы имеете дело с небольшим количеством сервисов. Например, как говорится
в главе 13, при переходе на микросервисы вы, скорее всего, будете использовать
тот же механизм, что и в монолитной версии приложения (и чаще всего это дан
ный вариант). О подготовке развитой инфраструктуры развертывания следует
задуматься только после того, как у вас будут готовы несколько сервисов.
□ Одно из многих преимуществ сети сервисов (сетевого слоя, который пропускает
через себя весь входящий и исходящий трафик сервиса) — возможность проте
стировать свой код в промышленных условиях, прежде чем направлять к нему
реальный трафик. Разделение развертывания и выпуска сервисов делает «вы
катывание» их новых версий более надежным.
Процесс перехода
на микросервисы
Надеюсь, эта книга позволила вам хорошо разобраться в микросервисной архитек
туре — ее преимуществах, недостатках и сценариях использования. Тем не менее
вы, скорее всего, работаете над большим и сложным монолитным приложением
и ежедневно сталкиваетесь с медленным и мучительным процессом разработки
и развертывания. А микросервисы при этом кажутся несбыточной мечтой, хотя
для вашего приложения они отлично подошли бы. Подобно Мэри с командой раз
работчиков FTGO, вы, наверное, задаетесь вопросом: как же, черт возьми, перейти
на микросервисную архитектуру?
К счастью, существуют стратегии, которые помогут вам выбраться из моно
литного ада, не переписывая весь свой код с нуля. Вы постепенно превратите свой
монолит в микросервисы, разрабатывая так называемое удушающее приложение
(strangler application). Идея этого подхода навеяна фикусом-душителем, расту
щим в тропических лесах, который оплетает и иногда убивает деревья. Удушающее
50 Глава 1 • Побег из монолитного ада
□ Предшественник — предшествующий шаблон, который обосновывает потреб
ность в данном шаблоне. Например, микросервисная архитектура — это пред
шественник всех остальных шаблонов в языке шаблонов, кроме монолитной
архитектуры.
□ Преемник — шаблон, который решает проблемы, порожденные данным шабло
ном. Например, при использовании микросервисной архитектуры необходимо
применить целый ряд шаблонов-преемников, включая обнаружение сервисов
и шаблон «Предохранитель».
□ Альтернатива — альтернативное решение по отношению к данному шаблону.
Например, монолитная и микросервисная архитектуры — это альтернативные
способы проектирования приложения. Нужно выбрать одну из них.
□ Обобщение — обобщенное решение проблемы. Например, в главе 12 представлены
разные реализации шаблона «Один сервис — один сервер».
□ Специализация — специализированная разновидность шаблона. I (апример, в гла
ве 12 вы узнаете, что развертывание сервиса в виде контейнера — это частный
случай шаблона «Один сервис — один сервер».
Кроме того, можно группировать шаблоны по областям, в которых их применяют.
Явное описание родственных шаблонов помогает получить представление о том, как
эффективно решить ту или иную проблему.
Пример визуального представления связей между шаблонами приведен на
рис. 1.9.
Рис. 1.9. Визуальное представление разного вида связей между шаблонами
13.1. Переход на микросервисы 497
намного проще развивать свой стек технологий. Однако переход с монолита на
микросервисы — задача не из легких. Он потребует ресурсов, необходимых для раз
работки новых функций. В итоге руководство, скорее всего, одобрит такой переход
только в случае, если это поможет решить существенные бизнес-проблемы.
Если вы попали в монолитный ад, у вас уже наверняка есть как минимум одна
проблема на уровне бизнеса. Далее приводятся примеры бизнес-проблем, присущих
монолитам.
□ Медленная доставка. В приложении сложно разобраться, затрудняются его
обслуживание и тестирование, что понижает продуктивность разработчиков.
В итоге организация не может эффективно функционировать и рискует уступить
конкурентам.
□ Обновления с ошибками. Из-за плохой тестируемости новые выпуски ПО часто
содержат ошибки. Это может привести к потере недовольных клиентов и сни
жению прибыли.
□ Плохая масштабируемость. Трудность масштабирования монолитного прило
жения связана с тем, что оно сочетает в одном исполняемом компоненте модули
с кардинально разными требованиями к ресурсам. Это означает, что с какого-то
момента масштабирование приложения становится либо непомерно дорогим,
либо невозможным. В итоге оно не в состоянии удовлетворить текущие или за
планированные потребности бизнеса.
Важно убедиться в том, что эти проблемы вызваны незрелой архитектурой,
ведь причиной медленного развертывания и низкокачественных обновлений часто
становится плохая организация процесса разработки. Например, если вы все еще
применяете ручное тестирование, одна лишь его автоматизация может существен
но ускорить темп доставки кода. Точно так же проблемы со стабильностью иногда
можно решить без изменения архитектуры. Вначале следует попробовать простые
решения. И только если они не дали эффекта и вы все равно испытываете трудности
с развертыванием программного обеспечения, имеет смысл мигрировать на микро
сервисную архитектуру. Давайте посмотрим, как это делается.
13.1.2. «Удушение» монолита
Преобразование монолитного приложения в микросервисы — это разновидность
модернизации ПО (en.wikipedia.org/wiki/Software_modemization). Модернизация ПО — это
процесс перевода устаревшего кода на новые архитектуру и стек технологий. Раз
работчики десятилетиями модернизируют свои приложения. Опыт, накопленный за
это время, можно применить для миграции на микросервисную архитектуру. Самый
важный урок состоит в том, что не стоит переписывать проект с нуля.
Начать с чистого листа и оставить старую кодовую базу в прошлом — зву
чит заманчиво. Но это чрезвычайно рискованный подход, который, скорее всего,
498 Глава 13 • Процесс перехода на микросервисы
закончится неудачей. На дублирование существующей функциональности уйдут
месяцы, возможно, годы, а бизнес-нужды требуют реализации новых возможностей
уже сегодня! К тому же вам все равно придется заниматься разработкой старого при
ложения, что будет отвлекать вас от переписывания, из-за этого сроки завершения
работы будут постоянно сдвигаться. Что еще хуже, вы вполне можете потратить
время на реализацию функций, которые больше не нужны. Мартину Фаулеру при
писывают такое высказывание: «Переписывание с нуля гарантирует лишь одно —
ноль!» (www.randyshoup.com/evolutionary-architecture).
Вместо того чтобы начинать с чистого листа, вы должны постепенно транс
формировать свой монолитный проект (рис. 13.1), построив новое удушающее
приложение. Оно состоит из микросервисов, работающих в связке с монолитным
кодом. Со временем монолитное приложение будет реализовывать все меньше
и меньше функций, пока полностью не исчезнет или не превратится в еще один
микросервис. Эта стратегия похожа на то, как если бы вы пытались ремонтировать
свою машину прямо на ходу. Это непросто, но куда менее рискованно, чем попытка
переписывания с нуля.
Рис. 13.1. Монолит постепенно заменяется удушающим приложением, состоящим
из сервисов. В конце концов монолит полностью исчезает или становится одним
из микросервисов
13.1. Переход на микросервисы 499
Мартин Фаулер называет эту стратегию модернизации шаблоном удушающего
приложения (strangler application, см. www.martinfowler.com/bliki/StranglerApplication.html).
Это название навеяно фикусом-душителем (см. ru.wikipedia.org/wiki/Фикусы-душители),
обитающим в тропических лесах. Он оплетает дерево, стремясь подняться над по
логом леса и достичь солнечных лучей. Дерево часто умирает либо из-за старости,
либо в процессе удушения, оставляя после себя древовидный фикус.
Процесс рефакторинга обычно занимает месяцы и даже годы. Например, если
верить Стиву Йегге (Steve Yegge), компания Amazon потратила несколько лет на
рефакторинг своего монолита. Если ваша система очень большая, трансформация
может так никогда и не завершиться. Например, может настать момент, когда разбие
ние монолита перестанет быть самой важной задачей и вы переключитесь, скажем,
на реализацию функций, которые приносят прибыль. Если монолит не препятствует
текущей разработке, возможно, не стоит его трогать.
Демонстрируйте пользу от перехода
как можно раньше и чаще
Важное преимущество постепенного перехода на микросервисную архитектуру со
стоит в том, что вы сразу же видите плоды своей работы. Это сильно отличается от
переписывания с нуля, о пользе которого можно судить только по его окончании.
При постепенном рефакторинге монолита каждый новый сервис можно писать
с помощью нового стека технологий и современного высокоскоростного процесса
разработки и развертывания. Благодаря этому ваша команда будет доставлять свой
код все быстрее и быстрее.
Кроме того, вы можете начать миграцию с самых значимых участков приложе
ния. Представьте, к примеру, что вы работаете над проектом FTGO и руководство
считает алгоритм планирования доставки ключевым конкурентным преимуществом.
В этом случае управление доставкой, скорее всего, будет постоянно находиться
в процессе разработки. Выделив его в отдельный сервис, вы сможете прикрепить
к нему команду, которая будет действовать независимо от своих коллег, что су
щественно повысит темп разработки. Эта команда сможет чаще выпускать новые
версии алгоритма и оценивать их эффективность.
Ранняя демонстрация результатов также помогает заручиться поддержкой ру
ководства в процессе перехода. Это крайне важно, поскольку рефакторинг отнимает
ресурсы, которые могли бы быть направлены на разработку новых возможностей.
500 Глава 13 • Процесс перехода на микросервисы
Некоторые организации испытывают трудности с устранением технической задол
женности из-за слишком амбициозных и, как оказывается впоследствии, малополез
ных планов. В итоге руководство сложно убедить в необходимости переписывания
проекта. Но благодаря тому, что переход на микросервисы проходит постепенно,
команда разработки может демонстрировать пользу этой идеи на ранних этапах
и с высокой частотой.
Минимизация изменений, вносимых в монолит
В этой главе мы постоянно будем возвращаться к тому, что при переходе на микро
сервисную архитектуру не стоит вносить масштабные изменения в монолит. Есте
ственно, в процессе миграции вам неизбежно придется что-то менять. В подразде
ле 13.3.2 говорится о том, что монолит часто нужно модифицировать для участия
в повествованиях, которые обеспечивают согласованность данных между ним
и сервисами. Такие масштабные изменения отнимают много времени, довольно
рискованны и дорогостоящи. В конце концов, это, наверное, основная причина, по
чему вы решили перейти на микросервисы.
К счастью, существуют стратегии, с помощью которых масштаб необходимых
изменений можно уменьшить. Например, в подразделе 13.2.3 показана стратегия
копирования информации из извлеченного сервиса обратно в базу данных моно
лита. А в подразделе 13.3.2 я покажу, как тщательно спланировать извлечение
сервиса, чтобы уменьшить его воздействие на монолит. Применяя эти стратегии,
вы сможете снизить объем работы, необходимой для рефакторинга монолитного
приложения.
Инфраструктура развертывания: не все сразу
В этой книге приводится множество новых блестящих технологий, включая плат
формы развертывания вроде Kubernetes и AWS Lambda, а также механизмы обна
ружения сервисов. У вас может появиться соблазн начать переход на микросервисы
с выбора этих технологий и построения инфраструктуры. Возможно даже, что руко
водство и дружественный поставщик услуг PaaS будут подталкивать вас к покупке
подобного рода решений.
Но как бы соблазнительно ни выглядела подготовка инфраструктуры, советую
делать как можно меньше предварительных инвестиций в ее построение. Един
ственное, без чего нельзя обойтись, — это процесс развертывания с автоматическим
тестированием. Например, если у вас всего несколько сервисов, вам не нужны раз
витые инструменты для развертывания и обеспечения наблюдаемости. В самом
начале для обнаружения сервисов можно использовать конфигурацию, встроенную
в исходный код. Я рекомендую отложить любые решения о технической стороне
инфраструктуры, требующие существенных инвестиций, до тех пор, пока вы не на
копите реальный опыт работы с микросервисной архитектурой. Только после за
пуска нескольких сервисов вы сможете выбрать подходящие технологии, хорошо
понимая, что делаете.
13.2. Стратегии перехода с монолита на микросервисы 501
Теперь рассмотрим стратегии, которые можно применять /для миграции на
микросервисы.
13.2. Стратегии перехода с монолита
на микросервисы
Существует три основные стратегии для удушения монолита и постепенной замены
его микросервисами.
1. Реализация новых возможностей в виде сервисов.
2. Разделение уровня представления и внутренних компонентов.
3. Разбиение монолита путем оформления функциональности в виде сервисов.
Первая стратегия предотвращает дальнейшее развитие монолита. Она позволя
ет быстро продемонстрировать выгоду от использования микросервисов, помогая
заручиться поддержкой руководства. Две другие стратегии разбивают монолит на
части. Второй подход может стать полезным в процессе рефакторинга, а без третьего
вы точно не обойдетесь, поскольку именно так функциональность переносится из
монолита в удушающее приложение.
Рассмотрим каждую из этих стратегий, начиная с реализации новых возмож
ностей в виде сервисов.
13.2.1. Реализация новых возможностей в виде сервисов
Закон ямы гласит: «Если вы оказались в яме, перестаньте копать» (en.m.wikipe-
dia.org/wiki/Law_of_holes). Это отличный совет на случай, когда монолитное прило
жение становится сложно поддерживать. Иными словами, если у вас есть большой
и сложный монолитный проект, прекратите добавлять в него новые возможности,
иначе он станет еще более крупным и неуправляемым. Вместо этого новые функции
следует реализовывать в виде сервисов.
Это отличный способ начать перевод монолитного приложения на микросервис
ную архитектуру, снизив темпы его развития. Данный подход ускоряет реализацию
новых функций, поскольку разработка происходит в совершенно новой кодовой
базе. Он также позволяет быстро продемонстрировать выгоды от перехода на микро
сервисы.
Интеграция нового сервиса с монолитом
Архитектура приложения после реализации новой возможности в виде сервиса по
казана на рис. 13.2. Помимо нового сервиса и монолита, она содержит два других
компонента, которые интегрируют новый код в приложение:
□ API-шлюз — направляет запросы новой функциональности к новым сервисам,
а старые запросы — к монолиту;
502 Глава 13 • Процесс перехода на микросервисы
□ интеграционный связующий код — интегрирует сервисы в монолит. Позволяет
сервису обращаться к данным и функциям, принадлежащим монолиту.
Рис. 13.2. Новая возможность реализуется в виде сервиса, входящего в удушающее приложение.
Интеграционный слой связывает сервис с монолитом и состоит из адаптеров, которые реализуют
синхронные и асинхронные API. API-шлюз направляет к сервису запросы, которые обращаются
к новым функциям
Интеграционный код не является самостоятельным компонентом. Он состоит
из адаптеров к монолиту и сервисов, которые применяют один или несколько
механизмов межпроцессного взаимодействия. Например, связующий слой для
сервиса Delayed Delivery, описанного в подразделе 13.4.1, использует как REST,
так и доменные события. Сервис извлекает из монолита информацию о контракте
клиента, обращаясь к REST API. Монолит публикует доменные события Order,
чтобы сервис Delayed Delivery мог отслеживать состояние заказов и реагировать
на несвоевременную доставку. В подразделе 13.3.1 интеграционный код описыва
ется подробнее.
13.2. Стратегии перехода с монолита на микросервисы 503
Когда новую функцию следует реализовывать
в виде сервиса
В идеале каждая новая возможность должна быть реализована в удушающем при
ложении, а не в монолите. Для этого создается новый или дополняется уже суще
ствующий сервис. Таким образом вы сможете избежать дальнейшей модификации
монолитного кода. К сожалению, это не всегда возможно.
Дело в том, что микросервисная архитектура, в сущности, представляет собой
набор слабо связанных сервисов, организованных вокруг бизнес-возможностей.
Возможность может оказаться слишком мелкой для того, чтобы быть значимым
сервисом, и вы просто добавите несколько полей и методов в существующий класс.
Или же новая функциональность слишком тесно связана с кодом монолита. Если вы
попытаетесь реализовать ее в виде сервиса, это может вылиться в излишнее меж
процессное взаимодействие и, как следствие, ухудшение производительности. У вас
могут возникнуть проблемы с согласованностью данных. Возможность, для которой
нельзя выделить отдельный сервис, обычно вначале реализуют в монолите. Позже
ее можно будет извлечь вместе с другими связанными функциями.
Реализация новых возможностей в виде сервисов ускоряет их разработку. Это хо
роший способ быстро продемонстрировать преимущества микросервисной архитек
туры. К тому же это замедляет темпы развития монолита. Но в конечном итоге вам
необходимо разбить монолит на части, используя две другие стратегии. Вы должны
перенести функциональность в удушающее приложение, извлекая ее из монолита
в сервисы. Вы также можете ускорить темп разработки, разбивая монолит горизон
тально. Посмотрим, как это делается.
13.2.2. Разделение уровня представления
и внутренних компонентов
Одна из стратегий сокращения монолитного приложения заключается в отделении
уровня представления от бизнес-логики и слоя доступа к данным. Типичное про
мышленное приложение включает следующие слои.
□ Логика представления — состоит из модулей, которые обрабатывают НТТР-
запросы и генерируют HTML-страницы с пользовательским веб-интерфейсом.
В приложениях, обладающих сложным пользовательским интерфейсом, слой
представления занимает существенную часть кода.
□ Бизнес-логика — состоит из модулей, реализующих бизнес-правила. В промыш
ленных приложениях может быть довольно сложной.
□ Логика доступа к данным — состоит из модулей для доступа к инфраструктурным
сервисам, таким как базы данных и брокеры сообщений.
Уровень представления обычно четко отделен от бизнес-логики и прослойки
для доступа к данным. Бизнес-логика обладает обобщенным API с одним или
504 Глава 13 • Процесс перехода на микросервисы
несколькими фасадами, которые ее инкапсулируют. Этот API представляет собой
естественный шов, вдоль которого монолит можно разделить на два более мелких
приложения (рис. 13.3).
<
Рис. 13.3. Разделение клиентских и внутренних компонентов позволяет развертывать их отдельно
друг от друга и открывает API для сервисов
Одно приложение может содержать слой представления, а другое — бизнес-
логику и прослойку для доступа к данным. После разделения логика представления
и бизнес-логика будут общаться посредством удаленных вызовов.
Разделение монолита подобным образом обеспечивает два основных преиму
щества. Оно позволяет разрабатывать, развертывать и масштабировать два при
ложения независимо друг от друга. В частности, разработчики слоя представления
могут ускорить темпы развития пользовательского интерфейса и, например, легко
проводить А/В-тестирование, не развертывая внутреннюю часть. Еще одна поло
жительная сторона этого подхода — открытие доступа к удаленному API, который
смогут вызывать будущие микросервисы.
13.2. Стратегии перехода с монолита на микросервисы 505
Но это лишь часть решения. Как минимум одно из этих приложений (или оба
сразу) почти наверняка будет таким же неуправляемым монолитом. Чтобы заменить
монолит сервисами, необходимо применить третью стратегию.
13.2.3. Извлечение бизнес-возможностей
в сервисы
Реализация новых возможностей в виде сервисов и отделение клиентского веб
приложения от внутренних компонентов — это лишь полдела. Вам все равно при
дется активно работать с кодовой базой монолита. Если вы хотите существенно
улучшить архитектуру своего приложения и ускорить темпы разработки, разбейте
монолит на части, постепенно перенося его бизнес-возможности в сервисы. Напри
мер, в разделе 13.5 показано, как вынести управление доставкой из монолита FTGO
в новый сервис Delivery. При использовании этой стратегии количество бизнес-
возможностей, реализованных в виде сервисов, будет постепенно увеличиваться,
а монолитный код — понемногу сокращаться.
Для извлечения функций в сервисы необходимо брать вертикальный срез моно
лита, который состоит из следующих компонентов:
□ входящих адаптеров, реализующих конечные точки API;
□ доменной логики;
□ исходящих адаптеров, таких как логика доступа к БД;
□ схемы базы данных монолита.
Как показано на рис. 13.4, этот код извлекается из монолита и помещается в от
дельный сервис. API-шлюз направляет вызовы извлеченных бизнес-возможностей
к новому сервису, а остальные запросы оставляет на откуп монолиту. Монолит
и сервис взаимодействуют через интеграционный связующий код. Как описывается
в подразделе 13.3.1, этот код состоит из размещенных по обе стороны адаптеров,
которые используют один или несколько механизмов межпроцессного взаимодей
ствия (IPC).
Извлечение сервисов — непростая задача. Вам нужно решить, как разбить домен
ную модель монолита на отдельные модели, одна из которых будет принадлежать
сервису. Для вынесения функциональности придется разорвать зависимости, такие
как объектные ссылки, и, возможно, разделить существующие классы. А также мо
дифицировать структуру базы данных.
Извлечение сервисов обычно занимает много времени, особенно если кодо
вая база монолита запутанная, что далеко не редкость. В связи с этим вам следует
тщательно подумать над тем, какие сервисы нужно извлечь. Необходимо сосре
доточиться на рефакторинге тех частей приложения, которые приносят большую
пользу. Но прежде всего нужно спросить себя, какую выгоду вы от этого по
лучите.
506 Глава 13 • Процесс перехода на микросервисы
Р
и
с.
1
3.
4.
Р
аз
би
ен
ие
м
он
ол
ит
а
пу
те
м
и
зв
ле
че
ни
я
се
рв
ис
ов
. В
ам
н
уж
но
о
пр
ед
ел
ит
ь
ср
ез
ф
ун
кц
ио
на
ль
но
ст
и,
с
ос
то
ящ
ий
и
з
би
зн
ес
-л
ог
ик
и
и
ад
ап
те
ро
в,
и
в
ы
не
ст
и
ег
о
в
се
рв
ис
. С
ве
ж
еи
зв
ле
че
нн
ы
й
се
рв
ис
и
м
он
ол
ит
в
за
им
од
ей
ст
ву
ю
т
че
ре
з
AP
I,
п
ре
до
ст
ав
ле
нн
ы
е
св
яз
ую
щ
им
с
ло
ем
13.2. Стратегии перехода с монолита на микросервисы 507
Например, имеет смысл извлечь сервис, функции которого постоянно эволюцио
нируют и имеют критическое значение с точки зрения бизнеса. Если извлечение
не приносит существенной выгоды, не стоит тратить на него время. Позже в этом
разделе я опишу стратегии для определения того, что и когда следует извлекать.
Но сначала подробнее рассмотрим некоторые трудности, с которыми вы можете
столкнуться при вынесении кода в сервисы, и то, как с ними справиться.
В ходе извлечения сервисов вы столкнетесь с двумя непростыми задачами:
□ разделением доменной модели;
□ рефакторингом базы данных.
Рассмотрим их.
Разделение доменной модели
Чтобы извлечь сервис, необходимо вычленить его доменную модель из доменной
модели монолита. Разделение доменных моделей требует хирургической точно
сти. Одна из проблем, с которой вы столкнетесь, состоит в устранении объектных
ссылок, которые могут выйти за границы сервиса. Вполне возможно, что класс,
оставшийся в монолите, ссылается на классы, вынесенные в сервис, и наоборот.
Представьте, к примеру, ситуацию, изображенную на рис. 13.5: в результате извлече
ния сервиса Order одноименный класс ссылается на класс Restaurant, находящийся
в монолите. Поскольку сервис обычно работает в отдельном процессе, сохранять
объектные ссылки, выходящие за его пределы, попросту нельзя. От этой ссылки
нужно как-то избавиться.
Рис. 13.5. Доменный класс Order содержит ссылку на класс Restaurant. Если выделить Order
в отдельный сервис, с этой ссылкой нужно что-то сделать, поскольку между процессами
ее быть не должно
Эту проблему можно рассматривать с точки зрения агрегатов DDD, описан
ных в главе 5. Агрегаты ссылаются друг на друга с помощью первичных ключей,
508 Глава 13 • Процесс перехода на микросервисы
а не объектных ссылок. Представим классы Order и Restaurant в виде агрегатов
(рис. 13.6) и заменим ссылку на Restaurant внутри Order полем restaurantld, кото
рое будет хранить значение первичного ключа.
Одна из проблем с заменой объектных ссылок первичными ключами состоит
в том, что, несмотря на свою незначительность в контексте класса, она может серьез
но повлиять на его клиентов, которые по-прежнему работают со ссылками. Позже
в этом разделе я покажу, как ограничить область изменения путем репликации дан
ных между сервисом и монолитом. Сервис Delivery, к примеру, может определить
класс Restaurant, который будет копией одноименного класса монолита.
Рис. 13.6. Объектную ссылку на Restaurant внутри класса Order заменяют первичным ключом,
чтобы избавиться от объекта, выходящего за пределы процесса
Извлечение сервиса обычно намного сложнее, чем простое вынесение класса це
ликом. Куда более проблемным аспектом разделения доменной модели является из
влечение функциональности из класса, у которого есть другие обязанности. Эта си
туация обычно возникает с так называемыми божественными классами (см. главу 2),
которым делегирована слишком большая ответственность. Пример таких классов
в приложении FTGO — Order. Он реализует несколько бизнес-возможностей, вклю
чая управление заказами, доставкой и т. д. В разделе 13.5 вы увидите, каким образом
вынесение управления доставкой в отдельный сервис связано с извлечением класса
Delivery из Order. Сущность Delivery реализует функции управления доставкой,
которые прежде были встроены в класс Order.
Рефакторинг базы данных
Разделение доменной модели не ограничивается модификацией кода. Многие ее
классы хранят свое содержимое на постоянной основе. Их поля накладываются
на схему базы данных. Следовательно, вместе с сервисом из монолита извлекаются
еще и данные. Вам придется переместить таблицы из БД монолита в БД сервиса.
Кроме того, при разделении сущности нужно также разделить соответствующую
таблицу базы данных и переместить ее часть в сервис. Например, когда управление
доставкой выделяется в отдельный сервис, разделяется сущность Order и извлека
ется сущность Delivery. На уровне базы данных разделяется таблица ORDERS, часть
которой оформляется в виде новой таблицы DELIVERY. Затем DELIVERY перемещается
в сервис.
13.2. Стратегии перехода с монолита на микросервисы 509
В книге Скотта Амблера (Scott W. Ambler) и Прамодкумара Дж. Садаладжа
(Pramodkumar J. Sadalage) Refactoring Databases (AddisonWesley, 2011)1 описывается
ряд методик рефакторинга схемы базы данных. Например, там можно найти рефак
торинг типа «разделение таблицы» (split table), который разбивает одну таблицу на
две или больше. Многие методики, рассмотренные в этой книге, могут пригодиться
при извлечении сервисов из монолита. В качестве примера можно привести идею
репликации данных для постепенного перевода клиентов БД на новую схему.
Мы можем применить этот подход для ограничения области изменений, которые
необходимо внести в монолит в ходе извлечения сервисов.
Репликация данных для ограничения
масштаба изменений
Как уже упоминалось, извлечение сервиса требует изменения доменной модели
монолита. Например, вам нужно заменить объектные ссылки первичными ключа
ми и разделить классы. Подобного рода модификации могут пронизывать кодовую
базу, требуя внесения широкомасштабных изменений в монолит. Скажем, если вы
разделите сущность Order и извлечете сущность Delivery, вам придется исправлять
каждый участок кода, который ссылается на перемещенные поля. Подобного рода
правки могут занять чрезвычайно много времени и стать существенной преградой
на пути разбиения монолита.
Такие затратные изменения можно отложить и даже сделать их необязательны
ми, если воспользоваться методикой, аналог которой описан в упомянутой книге.
Рефакторинг базы данных существенно затрудняется из-за того, что всех ее клиентов
следует перевести на новую схему. Предложенное решение состоит в том, чтобы
сохранить исходную схему на время переходного периода и использовать триггеры
для ее синхронизации с новыми схемами. При этом клиенты постепенно мигрируют
со старой схемы на новую.
Аналогичный подход можно применить для извлечения сервисов из монолита.
Например, при извлечении сущности Delivery сущность Order временно остается
почти неизменной. Поля, относящиеся к доставке, остаются доступными только для
чтения и поддерживаются в актуальном состоянии за счет копирования данных из
сервиса Delivery обратно в монолит (рис. 13.7). В итоге нужно лишь найти в коде
монолита участки, которые обновляют эти поля, и сделать так, чтобы они обраща
лись к новому сервису Delivery.
Сохранение структуры сущности Order путем репликации данных из сервиса
Delivery существенно уменьшает объем начальных работ. Со временем мы можем
перенести в сервис Delivery код, который использует поля сущности Order или
столбцы таблицы ORDERS, относящиеся к доставке. Более того, вполне возможно, что
нам никогда не придется вносить эти изменения в монолит. Другие сервисы могут
обращаться к коду, извлеченному в сервис Delivery.
Амблер С., Садаладж П. Рефакторинг баз данных. — М.: Вильямс, 2016.
510 Глава 13 • Процесс перехода на микросервисы
Р
и
с.
1
3.
7.
М
ин
им
из
ир
уе
м
о
бъ
ем
и
зм
ен
ен
ий
, в
но
си
м
ы
х
в
м
он
ол
ит
F
TG
O
, р
еп
ли
ци
ру
я
да
нн
ы
е
о
до
ст
ав
ке
и
з
св
еж
еи
зв
ле
че
нн
ог
о
се
рв
ис
а
D
el
iv
er
y
об
ра
тн
о
в
ба
зу
д
ан
ны
х
м
он
ол
ит
а
13.2. Стратегии перехода с монолита на микросервисы 511
Какие сервисы и в какой момент нужно извлекать
Как я уже упоминал, разбиение монолита занимает много времени и отвлекает от
реализации новых возможностей. В связи с этим следует тщательно продумать по
следовательность извлечения сервисов. Приоритет должны иметь сервисы, которые
приносят наибольшую выгоду. Кроме того, вы постоянно должны демонстрировать
руководству пользу, которую приносит миграция на микросервисную архитектуру.
В этом деле важно знать, куда мы движемся. Переход на микросервисы стоит
начать с проектирования. Вы должны собраться и на протяжении короткого вре
мени — скажем, двух недель — направить все свои усилия на создание идеальной
архитектуры и определение набора сервисов. Это даст вам цель, к которой можно
стремиться, но помните, что архитектура не высекается в камне. По мере разбиения
монолита и приобретения опыта вы должны пересматривать то, что спроектировали,
с учетом приобретенных знаний.
Вслед за определением цели можно приступать к разбиению монолита на части.
Существует несколько стратегий, с помощью которых можно определить последо
вательность извлечения сервисов.
Первая стратегия состоит в фактической заморозке работы над монолитом и из
влечении сервисов по мере необходимости. Вместо того чтобы реализовывать новые
возможности или исправлять ошибки в монолите, вы извлекаете нужный (-ые)
сервис (-ы) и делаете это в нем (них). Одно из преимуществ этого подхода — то, что
он заставляет вас разбивать монолит. Его недостаток заключается в том, что мотива
цией для извлечения сервисов служат краткосрочные требования, а не долгосрочные
потребности. Например, сервисы извлекаются даже при внесении малейшего из
менения в относительно стабильную часть системы. В итоге вы рискуете потратить
много усилий с минимальной отдачей.
Альтернативная стратегия больше заботится о планировании: модулям прило
жения назначается рейтинг в соответствии с тем, какую пользу вы ожидаете полу
чить от их извлечения. Есть три причины, почему извлечение сервиса может быть
полезным.
□ Ускорение разработки. Если согласно плану какая-то часть вашего приложения
будет активно развиваться на протяжении следующего года, разработку можно
ускорить путем извлечения ее в сервис.
□ Решение проблем с производительностью, масштабируемостью и надежностью.
Если определенная часть вашего приложения ненадежна или имеет проблемы
с производительностью или масштабируемостью, будет полезно преобразовать
ее в сервис.
□ Возможность извлечь какие-то другие сервисы. Иногда из-за зависимостей между
модулями извлечение одного сервиса упрощает извлечение другого.
Вы можете использовать эти критерии для определения приоритета задач рефак
торинга согласно предполагаемой пользе. Преимущество данного подхода заклю
чается в его стратегичности и более тесной связи с потребностями бизнеса. В ходе
интенсивного планирования следует решить, что более выгодно — реализовывать
новые возможности или извлекать сервисы.
512 Глава 13 • Процесс перехода на микросервисы
13.3. Проектирование взаимодействия
между сервисом и монолитом
Сервисы редко являются автономными. Обычно им приходится взаимодействовать
с монолитом. Иногда сервису нужно обратиться к данным монолита или вызвать его
операции. Например, сервис Delayed Delivery, описанный в разделе 13.4.1, должен
получить доступ к информации о заказах и клиентах, принадлежащей монолиту.
Монолиту тоже могут понадобиться данные или операции сервиса. Например, в раз
деле 13.5, где обсуждается извлечение управления заказами в сервис, вы увидите,
что монолиту необходимо обратиться к сервису Delivery.
Одним из важных моментов является поддержание согласованности данных
между сервисом и монолитом. В частности, при извлечении сервиса вы разделяете
то, что прежде было ACID-транзакциями. Вы должны внимательно следить за со
хранением согласованности. Как будет показано позже в этом разделе, для этого
иногда применяются повествования.
Ранее уже упоминалось, что взаимодействие между сервисом и монолитом
осуществляется с помощью связующего кода. Структура такой интеграционной
прослойки показана на рис. 13.8. Она состоит из адаптеров, размещенных в сервисе
и монолите, которые общаются между собой с использованием некоего механизма
IPC. В зависимости от требований этот механизм может быть основан на REST или
обмене сообщениями. Кроме того, механизмов IPC может быть несколько.
Рис. 13.8. При переходе с монолита на микросервисы обеим сторонам, сервисам и монолиту, часто
нужно обращаться к данным друг друга. Это взаимодействие выполняется через интеграционный
слой, который состоит из адаптеров, реализующих API. Некоторые интерфейсы основаны на
обмене сообщениями, другие — на RPI
Например, сервис Delayed Delivery использует как REST, так и доменные со
бытия. Контактную информацию о клиентах он извлекает из монолита с помощью
13.3. Проектирование взаимодействия между сервисом и монолитом 513
REST, а для отслеживания состояния заказов подписывается на доменные события,
публикуемые монолитом.
Я начну этот раздел с описания структуры интеграционного слоя. Мы поговорим
о проблемах, которые он решает, и рассмотрим разные варианты его реализации.
После этого будут представлены стратегии управления транзакциями, включая ис
пользование повествований. Вы увидите, что иногда требование к согласованности
данных влияет на порядок извлечения сервисов.
Итак, рассмотрим структуру интеграционного слоя.
13.3.1. Проектирование интеграционного слоя
При реализации новой или извлечении существующей функции в виде сервиса вы
должны разработать интеграционный слой, который позволит этому сервису взаи
модействовать с монолитом. Его код будет находиться как в сервисе, так и в моно
лите и использовать некий механизм IPC. Тип выбранного механизма определяет
структуру интеграционного слоя. Если, к примеру, сервис обращается к монолиту
с помощью REST, этот слой состоит из REST-клиента в сервисе и веб-контроллеров
в монолите. Если же монолит подписывается на доменные события, публикуемые
сервисом, то интеграционный слой представляет собой адаптер, публикующий со
бытия (внутри сервиса), и обработчики событий (внутри монолита).
Проектирование API интеграционного слоя
Проектирование интеграционного слоя нужно начинать с определения того, какие
API он будет предоставлять доменной логике. Существует несколько стилей интер
фейсов, их выбор зависит от того, что вы делаете с данными — запрашиваете или
обновляете. Представьте, что вы работаете над сервисом Delayed Delivery, которому
необходимо извлекать из монолита контактную информацию клиентов. Бизнес-логике
сервиса не нужно знать о механизме IPC, с помощью которого монолит получает эту
информацию. Таким образом, механизм следует инкапсулировать в виде интерфей
са. Поскольку сервис Delayed Delivery запрашивает данные, имеет смысл определить
интерфейс CustomerContactlnfoRepository:
interface CustomerContactlnfoRepository {
CustomerContactlnfo findCustomerContactInfo(long customerld)
}
Бизнес-логика сервиса может обращаться к API, не зная при этом, как интегра
ционный слой извлекает данные.
Теперь рассмотрим другой сервис. Представьте, что вы извлекаете управление
доставкой из монолита FTGO. Для планирования, переноса и отмены доставки
монолиту теперь нужно обращаться к сервису Delivery. И снова бизнес-логике
неважны подробности реализации внутреннего механизма IPC, поэтому их лучше
инкапсулировать в виде интерфейса. В этом сценарии монолит должен вызывать
514 Глава 13 • Процесс перехода на микросервисы
операции сервиса, поэтому использование репозитория здесь не подходит. Интер
фейс сервиса лучше определить следующим образом:
interface Deliveryservice {
void scheduleDelivery(...);
void rescheduleDelivery(...);
void cancelDelivery(...);
}
Бизнес-логика обращается к этому интерфейсу, не зная, каким образом он реа
лизован в интеграционном слое.
Разобравшись с проектированием интерфейсов, можем поговорить о стилях
взаимодействия и механизмах IPC.
Выбор стиля взаимодействия и механизма IPC
Важное архитектурное решение при проектировании интеграционного слоя — выбор
стилей взаимодействия и механизмов IPC, которые позволят сервису и монолиту
общаться. Как говорилось в главе 3, в вашем распоряжении несколько таких стилей
и механизмов. Какие из них использовать, зависит от того, что именно требуется одной
стороне (сервису или монолиту) для запрашивания или обновления другой.
Если одной стороне необходимо запросить данные, принадлежащие другой, у вас
есть несколько вариантов. Первый вариант (рис. 13.9) состоит в том, чтобы адап
тер, реализующий интерфейс репозитория, обращался к API провайдера данных.
Этот API обычно основан на взаимодействии вида «запрос/ответ», таком как REST
или gRPC. Например, сервис Delayed Delivery может извлекать контактную инфор
мацию клиентов через интерфейс REST API, реализованный монолитом FTGO.
Рис. 13.9. Адаптер, реализующий интерфейс CustomerContactlnfoRepository, обращается к REST
API монолита, чтобы извлечь информацию о клиенте
В этом примере доменная логика сервиса Delayed Delivery извлекает контактную
информацию клиента, обращаясь к интерфейсу CustomerContactlnfoRepository.
Реализация этого интерфейса вызывает REST API монолита.
13.3. Проектирование взаимодействия между сервисом и монолитом 515
Важным преимуществом запрашивания данных через API является его про
стота, а основным недостатком — потенциальная неэффективность. Потребителю,
возможно, понадобится выполнить большое количество запросов, а провайдер
может вернуть большой объем данных. Еще одна проблема связана с ухудшением
доступности, поскольку мы используем синхронный механизм IPC. В итоге API для
выполнения запросов может оказаться нецелесообразным.
Альтернативный подход состоит в том, чтобы потребитель хранил у себя репли
ку данных (рис. 13.10). Эта реплика, в сущности, является CQRS-представлением.
Для поддержания ее в актуальном состоянии потребитель данных подписывается
на доменные события, публикуемые провайдером.
Рис. 13.10. Интеграционный слой реплицирует данные из монолита в сервис. Монолит публикует
доменные события, а их обработчик, реализованный в сервисе, обновляет базу данных с репликой
Использование реплики обеспечивает несколько преимуществ. Нам больше
не нужно раз за разом запрашивать данные у провайдера. Вместо этого, как было
показано при описании CQRS-представлений, реплику можно спроектировать для
поддержки эффективных запросов. Один из недостатков этого подхода — сложность
обслуживания реплики. Есть также потенциальная трудность, связанная с необхо
димостью изменения монолита для публикации доменных событий (об этом чуть
позже).
Итак, мы обсудили, как делать запросы. Теперь рассмотрим обновления. Труд
ность выполнения обновлений состоит в том, что необходимо поддерживать со
гласованность данных между сервисом и монолитом. Запрашивающая сторона уже
обновила или должна обновить свою базу данных. Поэтому важно сделать так, чтобы
были выполнены оба обновления. Для этого сервис и монолит могут общаться с по
мощью механизма транзакционных сообщений, реализованного таким фреймворком,
516 Глава 13 • Процесс перехода на микросервисы
как Eventuate Tram. В простых случаях запрашивающая сторона может послать
уведомление или опубликовать событие, чтобы инициировать обновление. Но в бо
лее сложных сценариях, чтобы добиться согласованности данных, она должна ис
пользовать повествования. Последствия применения повествований описываются
в подразделе 13.3.2.
Реализация предохранительного слоя
Представьте, что вы реализуете новую возможность в виде совершенно нового сер
виса. Вы не ограничены кодовой базой монолита, поэтому можете использовать со
временные методики разработки, такие как DDD, и создать новую доменную модель,
свободную от прежних недостатков. К тому же определение проблемной области
монолита FTGO нечеткое и слегка устаревшее, поэтому вы, скорее всего, смодели
руете все немного иначе. В итоге в доменной модели вашего сервиса будут другие
имена классов, названия полей и их значения. Например, сервис Delayed Delivery
содержит сущность Delivery с узким набором обязанностей, тогда как у монолита
FTGO есть сущность Order, на которую возложена чрезмерная ответственность.
Из-за такой разницы в доменных моделях вы должны реализовать то, что в терми
нологии DDD называется предохранительным слоем (anti-corruption layer, ACL).
Это позволит сервису общаться с монолитом.
Цель ACL состоит в том, чтобы не дать устаревшей доменной модели монолита
засорить доменную модель сервиса. Это слой кода, который посредничает между раз
ными доменными моделями. Например, у сервиса Delayed Delivery есть интерфейс
CustomerContactlnfoRepository с методом findCustomerContactInfo(), который воз
вращает Customercontact Inf о (рис. 13.11). Класс, реализующий этот интерфейс, должен
понимать языки для общения монолита FTGO с сервисом Delayed Delivery.
Реализация метода findCustomerContactInfo() обращается к монолиту, что
бы получить контактную информацию клиента, и преобразует ответ в объект
CustomerContactlnfo. В этом примере преобразование получилось довольно про
стым, но в других ситуациях оно может оказаться сложнее и включать в себя свя
зывание значений, таких как коды состояния.
Подписчик, потребляющий доменные события, тоже содержит слой ACL.
Доменные события являются частью доменной модели издателя. Обработчик дол
жен привести их к доменной модели подписчика. Например, монолит FTGO публи
кует доменные события Order, а сервис Delivery содержит обработчик, который на
них подписывается (рис. 13.12).
13.3. Проектирование взаимодействия между сервисом и монолитом 517
Рис. 13.11. Адаптер сервиса, который обращается к монолиту, должен выполнять преобразования
между доменными моделями сервиса и монолита
Рис. 13.12. Обработчик событий должен привести доменную модель издателя к доменной модели
подписчика
Обработчик должен выполнять преобразования между доменными событиями
монолита и сервиса Delivery, которые иногда включают в себя связывание классов,
имен атрибутов и их значений.
Предохранительный слой применяется не только в сервисах. Монолит тоже ис
пользует ACL, обращаясь к сервису и подписываясь на доменные события, которые
тот публикует. Например, монолит FTGO планирует доставку, отправляя уведоми
тельное сообщение сервису Delivery. Для этого он вызывает метод из интерфейса
Deliveryservice. Реализация класса преобразует его параметры так, чтобы сервис
Delivery мог их понять.
518 Глава 13 • Процесс перехода на микросервисы
Как монолит публикует и подписывается на события
Доменные события — важный механизм взаимодействия. В свежеразработанном сер
висе их публикация и потребление происходят довольно просто. Вы можете исполь
зовать один из механизмов, описанных в главе 3, таких как фреймворк Eventuate
Tram. Сервис может даже применить методику порождения событий, рассмотренную
в главе 6. Однако модификация монолита для публикации и потребления событий
оказывается потенциально непростой задачей. Давайте посмотрим почему.
Монолит может публиковать доменные события несколькими способами. Один
из подходов заключается в использовании того же механизма, который применяется
в сервисах. Для этого нужно найти все участки кода, где изменяется определенная
сущность, и вставить туда вызов API, публикующего события. Однако модификация
монолита не всегда проходит гладко. Поиск всех участков кода и добавление вызовов
для публикации событий может занять много времени и спровоцировать новые ошиб
ки. Ситуация усугубляется еще и тем, что бизнес-логика может содержать хранимые
процедуры, из которых нельзя так просто опубликовать доменные события.
Еще одним решением является публикация доменных событий на уровне базы
данных. Для этого можно, к примеру, отслеживать логику транзакций пассивным
или активным способом, как было описано в главе 3. Ключевое преимущество этого
подхода состоит в том, что вам не нужно модифицировать монолит. Но у публикации
доменных событий на уровне базы данных есть и обратная сторона: часто бывает
сложно определить причину обновления и опубликовать подходящее высокоуров
невое бизнес-событие. В итоге события, которые публикует сервис, представляют
скорее изменения таблиц, чем бизнес-сущностей.
К счастью, монолиту обычно проще подписаться на доменные события, публи
куемые сервисами. Довольно часто для написания обработчиков событий можно
использовать такие фреймворки, как Eventuate Tram. Но иногда не все так просто.
Например, монолит может быть написан на языке, у которого нет клиента для бро
кера сообщений. В таких ситуациях вам необходимо написать небольшое вспомога
тельное приложение, которое подписывается на события и обновляет базу данных
монолита напрямую.
Итак, мы обсудили проектирование интеграционного слоя, который делает воз
можным взаимодействие сервиса и монолита. Остановимся еще на одной проблеме,
с которой можно столкнуться при переходе на микросервисы: на обеспечении со
гласованности данных между сервисом и монолитом.
13.3.2. Обеспечение согласованности данных
между сервисом и монолитом
При разработке сервиса может обнаружиться, что согласование данных между серви
сом и монолитом вызывает определенные трудности. Сервису может понадобиться
обновить данные в монолите и наоборот. Представьте, к примеру, что вы извлекли
из монолита сервис Kitchen. Вам придется перевести операции для управления за
казами в монолитном коде, такие как createOrder() и cancelOrder(), на применение
повествований, чтобы сущности Ticket и Order оставались согласованными.
13.3. Проектирование взаимодействия между сервисом и монолитом 519
Однако проблема повествований в том, что участие монолита в них не всегда
проходит гладко. Как описывалось в главе 4, повествования должны использовать
компенсирующие транзакции для отмены изменений. Повествование Create Order,
например, помечает заказ как REJECTED, если сервис Kitchen его отклонил. Но для
поддержки компенсирующих транзакций в монолите придется внести многочис
ленные изменения и потратить много времени. К тому же монолиту, возможно, при
дется реализовать контрмеры, чтобы справиться с недостаточной изоляцией между
повествованиями. Стоимость таких изменений может стать большой проблемой
при извлечении сервиса.
К счастью, многие повествования реализуются довольно просто. Как было по
казано в главе 4, если транзакции монолита являются либо поворотными, либо
повторяемыми, проблем с реализацией повествования возникнуть не должно.
Вы можете даже упростить свою реализацию, тщательно спланировав последова
тельность извлечения сервисов таким образом, чтобы транзакции монолита никогда
не нужно было делать компенсируемыми. Однако внедрить в монолит поддержку
компенсируемых транзакций может оказаться непросто. Чтобы понять, чем это вы
звано, рассмотрим некоторые примеры, начиная с особенно проблемного.
Трудности изменения монолита
для поддержки компенсируемых транзакций
Давайте подробно рассмотрим проблему с компенсирующими транзакциями, которую
необходимо решить при извлечении из монолита сервиса Kitchen. Этот рефакторинг
подразумевает разделение сущности Order и создание в сервисе Kitchen сущности
520 Глава 13 • Процесс перехода на микросервисы
Ticket. Он затрагивает множество команд, реализованных в монолите, включая
createOrder().
Монолит реализует команду createOrder() в виде единой ACID-транзакции,
состоящей из следующих этапов.
1. Проверить детали заказа.
2. Убедиться в том, что клиент может размещать заказы.
3. Авторизовать банковскую карту клиента.
4. Создать заказ.
Эту ACID-транзакцию нужно заменить повествованием, которое состоит из
таких шагов.
1. В монолите:
• создать заказ с состоянием APPROVAL-PENDING;
• убедиться в том, что клиент может размещать заказы.
2. В сервисе Kitchen:
• проверить детали заказа;
• создать заявку с состоянием CREATE_PENDING.
3. В монолите:
• авторизовать банковскую карту клиента;
• изменить состояние заказа на APPROVED.
4. В сервисе Kitchen — изменить состояние заявки на AWAITING_ACCEPTANCE.
Это повествование похоже на CreateOrderSaga, описанное в главе 4. Оно состоит
из четырех локальных транзакций, по две в монолите и сервисе Kitchen. Первая
транзакция создает заказ с состоянием APPROVAL-PENDING. Вторая создает заявку
с состоянием CREATE_PENDING. Третья авторизует банковскую карту клиента и ме
няет состояние заказа на APPROVED. Четвертая, последняя, меняет состояние заявки
на AWAITING-ACCEPTANCE.
Сложность реализации этого повествования состоит в том, что его первый шаг,
на котором создается заказ, должен быть компенсируемым. Это связано с тем, что
вторая локальная транзакция, проходящая в сервисе Kitchen, может завершиться не
удачно и потребовать от монолита отмены изменений, внесенных первой локальной
транзакцией. В итоге сущность Order должна поддерживать контрмеру APPROVAL
PENDING — семантическую блокировку, описанную в главе 4, которая сигнализирует
о том, что заказ находится в процессе создания.
Проблема с добавлением нового состояния в сущность Order заключается в том,
что это может потребовать масштабной модификации монолита. Вам, вероятно, при
дется отредактировать каждый участок кода, который обращается к этой сущности.
Такое масштабное обновление монолита занимает много времени, являясь при этом
далеко не самым приоритетным аспектом разработки. К тому же это может оказаться
рискованной затеей, ведь монолит обычно сложно тестировать.
13.3. Проектирование взаимодействия между сервисом и монолитом 521
Повествования не всегда требуют от монолита поддержки
компенсируемых транзакций
Повествования сильно зависят от проблемной области. Некоторые (как только что
рассмотренное) требуют, чтобы монолит поддерживал компенсирующие транзакции.
Но извлечение сервисов вполне можно спланировать так, чтобы ваши повествования
не нуждались в реализации компенсирующих транзакций со стороны монолита.
Дело в том, что это необходимо, только последующие транзакции монолита могут
завершиться неудачно. Если же все транзакции монолита являются либо поворот
ными, либо повторяемыми, ему никогда не нужно будет ничего компенсировать.
В итоге для поддержки повествований в монолит нужно внести лишь небольшие
изменения.
Представьте, к примеру, что вместо Kitchen вы извлекаете сервис Order. В ходе
рефакторинга нужно разделить сущность Order в одноименном сервисе и сделать
ее более «тонкой». Изменения коснутся и многочисленных команд, таких как
createOrder(), которую следует перенести из монолита в сервис Order. Для извле
чения сервиса нужно сделать так, чтобы эта команда использовала повествование,
состоящее из следующих шагов.
1. Сервис Order — создать заказ с состоянием APPROVAL PENDING.
2. Монолит:
• убедиться в том, что клиент может размещать заказы;
• проверить детали заказа и создать заявку;
• авторизовать банковскую карту клиента.
3. Сервис Ordei— изменить состояние заказа на APPROVED.
Это повествование состоит из трех локальных транзакций: одна в монолите и две
в сервисе Order. Первая транзакция (в сервисе Order) создает заказ с состоянием
APPROVAL-PENDING. Вторая транзакция (в монолите) проверяет, может ли клиент
размещать заказы, авторизует его банковскую карту и создает заявку. Третья (опять
в сервисе Order) меняет состояние заказа на APPROVED.
Транзакция монолита является поворотной для этого повествования, его
точкой невозврата. Если она завершится успешно, повествование доработает
до самого конца. Проблемы могут возникнуть только с первыми двумя этапами.
Третья транзакция не может отказать, поэтому монолиту никогда не придется от
катывать вторую транзакцию. В результате вся сложность поддержки компенси
руемых транзакций ложится на сервис Order, который тестировать намного легче,
чем монолит.
Если все повествования, которые вы напишете при извлечении сервиса, будут
иметь такую структуру, вам придется сделать намного меньше изменений в моно
лите. Более того, извлечение сервисов можно тщательно спланировать в таком
порядке, чтобы все транзакции монолита были либо поворотными, либо повторя
емыми. Посмотрим, как это сделать.
522 Глава 13 • Процесс перехода на микросервисы
Планирование извлечения сервисов, чтобы избежать реализации
компенсирующих транзакций в монолите
Как мы только что видели, извлечение сервиса Kitchen требует от монолита реали-
зации компенсирующих транзакций, а извлечение сервиса Order — нет. Это говорит
о том, что порядок, в котором извлекаются сервисы, имеет значение. Если тщательно
его спланировать, можно избежать внесения масштабных изменений в монолит для
поддержки компенсируемых транзакций. Мы можем сделать так, чтобы все транз
акции в монолите были либо поворотными, либо повторяемыми. Например, если
извлечь из монолита FTGO сначала сервис Order, а затем Consumer, это упростит
извлечение сервиса Kitchen. Давайте посмотрим, как это делается.
После извлечения сервиса Consumer команда createOrder() использует следу
ющее повествование.
1. Сервис Order — создать заказ с состоянием APPROVAL_PENDING.
2. Сервис Consumer — убедиться в том, что клиент может размещать заказы.
3. Монолит:
• проверить детали заказа и создать заявку;
• авторизовать банковскую карту клиента.
4. Сервис Order — изменить состояние заказа на APPROVED.
В этом повествовании транзакция монолита является поворотной. Компенсиру
емую транзакцию реализует сервис Order.
Вслед за Consumer мы можем извлечь сервис Kitchen. Когда мы это сделаем, ко
манда createOrder() будет использовать следующее повествование.
1. Сервис Order — создать заказ с состоянием APPROVAL_PENDING.
2. Сервис Consumer — убедиться в том, что клиент может размещать заказы.
3. Сервис Kitchen — проверить детали заказа и создать заявку с состоянием PENDING.
4. Монолит — авторизовать банковскую карту клиента.
5. Сервис Kitchen — изменить состояние заявки на APPROVED.
6. Сервис Ordei— изменить состояние заказа на APPROVED.
В этом повествовании транзакция монолита по-прежнему остается поворотной.
Компенсируемые транзакции реализуются сервисами Order и Kitchen.
Мы можем продолжить рефакторинг монолита и извлечь сервис Accounting.
Если сделаем это, команда createOrder() будет использовать такое повествование.
1. Сервис Order — создать заказ с состоянием APPROVAL_PENDING.
2. Сервис Consumer — убедиться в том, что клиент может размещать заказы.
3. Сервис Kitchen — проверить детали заказа и создать заявку с состоянием
PENDING.
13.3. Проектирование взаимодействия между сервисом и монолитом 523
4. Сервис Accounting — авторизовать банковскую карту клиента.
5. Сервис Kitchen — изменить состояние заявки на APPROVED.
6. Сервис Order — изменить состояние заказа на APPROVED.
Как видите, тщательное планирование порядка извлечения позволяет избежать
использования повествований, которые требуют внесения сложных изменений
в монолит. Теперь посмотрим, как обеспечить безопасность при переходе на микро
сервисы.
13.3.3. Аутентификация и авторизация
Еще одна проблема, которую нужно решить при переводе монолитного приложения
на микросервисную архитектуру, — адаптация существующего механизма безопасно
сти для поддержки сервисов. Реализация механизма безопасности в микросервисной
архитектуре описана в главе 11. Приложение, основанное на микросервисах, переда
ет пользовательские данные с помощью токенов, таких как JWT (JSON Web token).
Это довольно сильно отличается от традиционного монолитного подхода, который
предусматривает хранение состояния сеанса в памяти и передачу пользовательских
данных с помощью внутрипоточных локальных переменных. Трудность состоит
в том, что вам придется одновременно поддерживать оба механизма безопасности,
как монолитный, так и основанный на JWT.
К счастью, существует простой и понятный способ решения этой проблемы,
который требует внести лишь одно небольшое изменение в обработчик входа в си
стему внутри монолита. На рис. 13.13 показано, как это работает. Обработчик входа
в систему возвращает дополнительный cookie-файл, который я в этом примере на
звал USERINFO. Он содержит информацию о пользователе, такую как его ID и роли.
Браузер включает этот cookie в каждый запрос. API-шлюз извлекает содержимое
cookie и добавляет его в HTTP-запрос, направленный к сервису. В итоге каждый
сервис получает доступ к необходимой пользовательской информации.
Происходит такая последовательность событий.
1. Клиент делает запрос входа в систему, содержащий учетные данные пользова
теля.
2. API-шлюз направляет запрос входа в систему к монолиту FTGO.
3. Монолит возвращает ответ с cookie сеанса JSESSIONID и cookie USERINFO, который
содержит такую пользовательскую информацию, как ID и роли.
4. Клиент делает запрос с cookie USERINFO внутри, чтобы вызвать операцию.
5. API-шлюз проверяет cookie USERINFO и включает его в заголовок Authorization
запроса, который шлет сервису. Сервис проверяет токен USERINFO и извлекает
информацию о пользователе.
Давайте подробнее рассмотрим LoginHandler и API-шлюз.
524 Глава 13 • Процесс перехода на микросервисы
Р
и
с.
1
3.
13
. М
од
иф
иц
ир
ов
ан
ны
й
об
ра
бо
тч
ик
в
хо
да
в
с
ис
те
м
у
ус
та
на
вл
ив
ае
т
co
ok
ie
, к
от
ор
ы
й
яв
ля
ет
ся
J
W
T
с
ин
ф
ор
м
ац
ие
й
о
по
ль
зо
ва
те
ле
.
АР
Рш
лю
з
пе
ре
да
ет
c
oo
ki
e
U
SE
RI
N
FO
в
з
аг
ол
ов
ке
а
вт
ор
из
ац
ии
п
ри
о
бр
ащ
ен
ии
к
с
ер
ви
су
13.4. Реализация новой возможности в виде сервиса 525
Обработчик LoginHandler монолита
устанавливает cookie USERINFO
LoginHandler обрабатывает POST-запрос с учетными данными. Он аутентифицирует
пользователя и сохраняет информацию о нем в сеансе. Часто этот процесс реализо
ван в фреймворках безопасности, таких как Spring Security или Passport для NodeJS.
Если приложение сконфигурировано для использования сеансов, хранимых в памя
ти, HTTP-ответ устанавливает cookie сеанса, такой как 3SESSIONID. Для поддержки
перехода на микросервисы обработчик LoginHandler должен также установить cookie
USERINFO с токеном JWT внутри, который описывает пользователя.
API-шлюз привязывает cookie USERINFO
к заголовку Authorization
Как говорилось в главе 8, API-шлюз отвечает за маршрутизацию запросов и объеди
нение API. При обработке каждого запроса он делает одно или несколько обраще
ний к монолиту и сервисам. Когда API-шлюз вызывает сервис, он проверяет cookie
USERINFO и передает его внутри HTTP-запроса в заголовке Authorization. Привязы
вая cookie к этому заголовку, он гарантирует, что сервис получит пользовательскую
информацию стандартным путем, который не зависит от типа клиента.
Рано или поздно мы, скорее всего, выделим вход в систему и управление поль
зователями в отдельные сервисы. Но, как видите, благодаря одному лишь не
большому изменению в обработчике монолита сервисы могут получить доступ
к пользовательской информации. Это позволяет сосредоточиться на разработке
сервисов, предоставляющих максимальную пользу с точки зрения бизнеса, и от
ложить на потом извлечение менее полезных возможностей, таких как управление
пользователями.
Итак, мы разобрались с тем, как обеспечить безопасность при переходе на микро
сервисы. Теперь рассмотрим пример реализации новой функциональности в виде
сервиса.
13.4. Реализация новой возможности
в виде сервиса
Представьте, что вам было поручено улучшить то, как FTGO работает с недостав
ленными заказами. Все больше клиентов жалуется на то, как служба поддержки
относится к заказам, которые не были доставлены. В большинстве случаев до
ставка происходит вовремя, но время от времени заказы задерживаются или вовсе
не доставляются. Например, если курьер неожиданно попадет в пробку, он заберет
и доставит еду с задержкой. Бывает и так, что в момент прибытия курьера ресторан
уже закрыт, поэтому доставка невозможна. Ситуация усугубляется еще и тем, что
служба поддержки узнает о недоставленном заказе из гневных писем недовольных
клиентов.
526 Глава 13 • Процесс перехода на микросервисы
Первопричина многих подобных проблем состоит в примитивном алгоритме
планирования доставки, который используется в приложении FTGO. Более слож
ный планировщик уже разрабатывается, но будет готов лишь через несколько ме
сяцев. А пока компания FTGO будет действовать на упреждение, извиняясь перед
клиентами за задержанные и отмененные заказы и в некоторых случаях предлагая
компенсацию еще до поступления жалобы.
От вас требуется реализовать следующие возможности.
1. Уведомление клиента в случае, если его заказ не будет доставлен вовремя.
2. Уведомление клиента в случае, если его заказ не будет доставлен из-за того, что
ресторан закрывается раньше отгрузки.
3. Уведомление работников службы поддержки о том, что заказ задерживается, что
бы они могли исправить ситуацию, заранее компенсировав клиенту неудобства.
4. Отслеживание статистики доставки.
Эти новые функции довольно просты. Новый код должен следить за состоянием
заказа и в случае, если его нельзя доставить в запланированное время, уведомлять
клиента и службу поддержки, например, электронным письмом.
Но как (или, если быть точным, где} реализовать эти новые возможности?
Для этого можно создать отдельный модуль в монолите. Но это вызвало бы труд
ности с разработкой и тестированием данного кода. К тому же в этом случае монолит
распухнет еще сильнее, что только усугубит ситуацию. Вспомните закон ямы, о ко
тором упоминалось ранее: если вы оказались в яме, нужно перестать копать. Лучше
реализовать эти возможности в виде сервиса, не увеличивая монолит.
13.4.1. Архитектура сервиса Delayed Delivery
Мы реализуем эти функции в виде сервиса Delayed Delivery. Этот процесс в контек
сте архитектуры приложения FTGO представлен на рис. 13.14. У нас есть монолит,
сервис Delayed Delivery и API-шлюз. У сервиса есть API с одной запрашивающей
операцией getDelayedOrders(), которая возвращает задерживающиеся и недо-
13.4. Реализация новой возможности в виде сервиса 527
ставленные заказы. API-шлюз направляет вызов getDelayedOrders() к сервису,
а остальные запросы — к монолиту. Интеграционный слой предоставляет сервису
доступ к данным монолита.
Доменная модель сервиса Delayed Order состоит из различных сущностей, вклю
чая DelayedOrderNotification, Order и Restaurant. Основная логика находится
в классе DelayedOrderServi.ee, он периодически вызывается по таймеру и находит
заказы, которые не будут доставлены вовремя. Для этого он обращается к сущ
ностям Order и Restaurant. Если заказ нельзя доставить в назначенное время,
DelayedOrderServi.ee уведомляет об этом клиента и службу поддержки.
Сущности Order и Restaurant не принадлежат сервису Delayed Order. Вместо это
го они реплицируются из монолита FTGO. Более того, сервис не хранит контактную
информацию клиента, а извлекает ее из монолита.
Рис. 13.14. Архитектура сервиса Delayed Delivery. Интеграционный слой предоставляет ему
принадлежащие монолиту данные, такие как сущности Order и Restaurant, а также контактную
информацию клиента
Рассмотрим структуру интеграционного слоя, который предоставляет сервису
Delayed Order доступ к данным монолита.
528 Глава 13 • Процесс перехода на микросервисы
13.4.2. Проектирование интеграционного слоя
для сервиса Delayed Order
Сервисы, реализующие новые возможности, определяют собственные классы сущ
ностей, но в то же время обычно обращаются к данным, принадлежащим монолиту.
Не исключение и сервис Delayed Delivery. Он содержит сущность DelayedOrderNo-
tif ication, которая представляет уведомление, отправляемое клиенту. Но, как я толь
ко что отметил, его сущности Order и Restaurant реплицируют данные из монолита
FTGO. Кроме того, чтобы уведомить пользователя, этому сервису нужно запросить
его контактную информацию. Таким образом, необходимо реализовать интеграцион
ный слой, который позволит сервису Delivery обращаться к данным монолита.
Архитектура этого интеграционного слоя показана на рис. 13.15. Монолит FTGO
публикует доменные события Order и Restaurant, а сервис Delivery их потребляет
и обновляет свои реплики соответствующих сущностей. Монолит FTGO предо
ставляет конечную точку REST для получения контактной информации клиента.
Сервис Delivery обращается к этой конечной точке, когда ему нужно уведомить
пользователя о том, что доставку нельзя выполнить вовремя.
Рис. 13.15. Интеграционный слой предоставляет сервису Delayed Order доступ к данным,
принадлежащим монолиту
Рассмотрим структуру каждого элемента интеграционного слоя, начиная с REST
API для получения контактной информации клиента.
Запрашивание контактной информации клиента с помощью
CustomerContactlnfoRepository
Как было сказано в подразделе 13.3.1, такой сервис, как Delayed Delivery, может
прочитать данные монолита несколькими способами. Самый простой вариант со-
стоит в извлечении данных монолита через его API. Он подходит для получения
13.4. Реализация новой возможности в виде сервиса 529
контактной информации пользователя. У нас не будет никаких проблем с латент
ностью и производительностью, поскольку сервис Delayed Delivery использует эту
операцию лишь изредка и объем передаваемых данных довольно небольшой.
CustomerContactlnfoRepository — это интерфейс, который позволяет сервису
Delayed Delivery извлекать контактную информацию клиентов. Его реализация —
класс CustomerContactlnfoProxy — извлекает нужные данные, обращаясь через REST
к getCustomerContactInfo() — конечной точке монолита.
Публикация и потребление доменных событий Order
и Restaurant
К сожалению, запрашивать все открытые заказы и время работы всех ресторанов
было бы непрактично. Это потребовало бы многократной передачи больших объ-
емов данных по сети. По этой причине сервис Delayed Delivery должен использовать
второй, более сложный вариант: хранить реплики Order и Restaurant, подписываясь
на события, которые публикует монолит. Не стоит забывать, что реплика — это
не полная копия данных монолита, она содержит лишь небольшое подмножество
атрибутов сущностей Order и Restaurant.
Как было сказано в подразделе 13.3.1, добавить в монолит FTGO поддержку
публикации доменных событий Order и Restaurant можно несколькими разными
способами. Во-первых, вы можете отредактировать все участки монолита, кото
рые обновляют Order и Restaurant, вставив туда публикацию высокоуровневых
доменных событий. Во-вторых — отслеживать журнал транзакций и репликации
изменений в виде событий. Публикация высокоуровневых доменных событий — не
обязательное требование, поэтому нам подойдет любой вариант.
Сервис Delayed Order реализует обработчики, которые подписываются на со
бытия монолита и обновляют его сущности Order и Restaurant. Детали их реали
зации зависят от того, какие события публикует монолит, высокоуровневые или
низкоуровневые (об изменениях). В любом случае мы можем сформулировать этот
процесс таким образом: обработчик берет событие из изолированного контекста
монолита и обновляет сущность в изолированном контексте сервиса.
У использования репликации есть важное преимущество: оно позволяет сервису
Delayed Order эффективно запрашивать заказы и время работы ресторанов. Его не
достатки — повышенная сложность и необходимость публикации событий Order
и Restaurant из монолита. К счастью, сервису Delayed Delivery нужна лишь часть
столбцов из таблиц ORDERS и RESTAURANT, поэтому у нас не должно возникнуть про
блем, описанных в подразделе 13.3.1.
Реализация новых возможностей, таких как управление опаздывающими зака
зами, в виде отдельных сервисов ускоряет их разработку, тестирование и разверты
вание. Более того, это дает возможность применять самые современные технологии
вместо более старого технологического стека монолита. Монолит при этом перестает
расти. Управление опаздывающими заказами — это лишь одна из новых функций,
530 Глава 13 • Процесс перехода на микросервисы
запланированных для приложения FTGO. Многие из них можно будет реализовать
в виде отдельных сервисов.
К сожалению, сервисы не могут вобрать в себя все обновления. Довольно часто
для реализации новых или изменения существующих возможностей приходится
существенно модифицировать сам монолит. Работа с монолитным кодом обычно
оказывается медленной и мучительной. Чтобы ускорить доставку таких модифи
каций, вы должны разбить монолит на части и перенести их функции в сервисы.
Давайте посмотрим, как это сделать.
13.5. Разбиение монолита на части: извлечение
управления доставкой
Чтобы ускорить доставку возможностей, реализованных в монолите, вы должны
разбить монолитный код на сервисы. Представьте, к примеру, что вам захотелось
улучшить управление доставкой за счет нового алгоритма маршрутизации. Основ
ной преградой на этом пути будет то, что данный механизм переплетается с управле
нием заказами и является частью кодовой базы монолита. Разработка, тестирование
и развертывание модуля управления доставкой, скорее всего, займут много времени.
Чтобы ускорить процесс, нужно извлечь этот модуль в сервис Delivery.
Для начала я опишу управление доставкой и объясню, как оно в настоящий
момент встроено в монолит. Затем мы поговорим о проектировании нового, от
дельного сервиса Delivery со своим API. Я покажу, как этот сервис взаимодействует
с монолитом FTGO. В конце мы рассмотрим изменения, которые необходимо внести
в монолит для поддержки сервиса Delivery.
Начнем с обзора текущей архитектуры.
13.5.1. Обзор возможностей существующего механизма
управления доставкой
Управление доставкой отвечает за планирование отгрузки заказов курьерам и их
доставку клиентам. У каждого курьера есть план действий по приему и доставке
заказов. Прием — это получение курьером заказа в ресторане в заданное время.
Доставка — передача заказа клиенту. Планы пересматриваются при размещении,
отмене или редактировании заказов, а также изменении местоположения и доступ
ности курьеров.
Управление доставкой — одна из самых старых частей приложения FTGO. Оно
встроено в систему управления заказами (рис. 13.16). Большая часть кода для ра
боты с доставкой находится в классе OrderService. Более того, у нас нет сущности,
которая бы явно представляла доставку. Этот код принадлежит сущности Order,
содержащей различные поля, связанные с доставкой, включая scheduledPickupTime
и scheduledDeliveryTime.
13.5. Разбиение монолита на части: извлечение управления доставкой 531
Рис. 13.16. В монолите FTGO управление доставкой переплетается с управлением заказами
Монолит реализует множество команд для управления доставкой:
□ acceptOrder() — вызывается, когда ресторан принимает заказ и обязуется под
готовить его к определенному времени. Эта операция включает в себя планиро
вание доставки;
□ cancelOrder() — вызывается, когда клиент отменяет заказ. В случае необходи
мости отменяет доставку;
□ noteCourierLocationUpdated() — вызывается мобильным приложением курьера
для обновления его местоположения. Приводит к перепланированию доставок;
532 Глава 13 • Процесс перехода на микросервисы
□ noteCourierAvailabilityChanged() — вызывается мобильным приложением
курьера для обновления его доступности. Приводит к перепланированию до
ставок.
Есть также различные запросы для извлечения данных, с которыми работает
механизм управления доставкой:
□ getCourierPlan() — вызывается мобильным приложением курьера и возвращает
его план действий;
□ getOrderStatus() — возвращает состояние заказа вместе с информацией о до
ставке, такой как назначенный курьер и ожидаемое время прибытия;
□ getOrderHistory() — возвращает то же, что и getOrderStatus(), только для не
скольких заказов.
Как упоминалось в подразделе 13.2.3, в сервис довольно часто извлекается
весь вертикальный срез с контроллерами вверху и таблицами базы данных внизу.
Команды и запросы, связанные с курьером, можно рассматривать как часть управ
ления доставкой. В конце концов, этот механизм создает планы действия курьеров
и является основным потребителем информации об их местоположении и доступно
сти. Но, чтобы минимизировать усилия, затрачиваемые на разработку, мы оставим эти
операции в монолите и извлечем только основную часть алгоритма. Таким образом,
у первой версии сервиса Delivery не будет публичного API, она будет вызываться
исключительно монолитом. Теперь исследуем архитектуру сервиса Delivery.
13.5.2. Обзор сервиса Delivery
Новый сервис Delivery, который мы хотим создать, отвечает за планирование, пере
планирование и отмену доставки. Обобщенное представление архитектуры прило
жения FTGO после извлечения сервиса Delivery показано на рис. 13.17. Монолит
и сервис общаются между собой с помощью интеграционного слоя, API которого
размещены по обе стороны. Сервис Delivery имеет собственные доменную модель
и базу данных.
Чтобы воплотить эту архитектуру в жизнь и определить доменную модель сер
виса, необходимо ответить на следующие вопросы.
□ Какие данные и логика перемещаются в сервис?
□ Какой API сервис Delivery предоставляет монолиту?
□ Какой API монолит предоставляет сервису Delivery?
Эти вопросы связаны между собой, так как распределение ответственности
между монолитом и сервисом влияет на API. Например, для доступа к данным, раз
мещенным в БД монолита, сервису Delivery нужно будет обращаться к API, которые
тот предоставляет, и наоборот. Позже я опишу структуру интеграционного слоя, ко
торый делает возможным общение сервиса Delivery и монолита FTGO. Но сначала
рассмотрим доменную модель сервиса Delivery.
13.5. Разбиение монолита на части: извлечение управления доставкой 533
Рис. 13.17. Обобщенная схема приложения FTGO после извлечения сервиса Delivery. Монолит
FTGO и сервис Delivery общаются с помощью интеграционного слоя, API которого размещены
по обе стороны. Необходимо принять два ключевых решения: какие данные и функциональность
перемещаются в сервис Delivery и через какие API монолит и сервис Delivery будут
взаимодействовать между собой
13.5.3. Проектирование доменной модели
сервиса Delivery
Чтобы извлечь управление доставкой, нужно сначала определить классы, которые ее
реализуют. Сделав это, мы сможем решить, какие классы следует переместить в сер
вис Delivery, чтобы сформировать его доменную логику. Иногда классы приходится
разделять. Также нужно определиться с тем, какие данные будут реплицироваться
между сервисом и монолитом.
Для начала выделим классы, которые реализуют управление доставкой.
Какие сущности и их поля относятся
к управлению доставкой
Первое, что нужно сделать при проектировании сервиса Delivery, — тщательно про
анализировать код управления доставкой и определить, какие сущности и их поля
в этом участвуют. Сущности и их поля, которые принимают участие в управлении
534 Глава 13 • Процесс перехода на микросервисы
доставкой, показаны на рис. 13.18. Некоторые из полей служат параметрами для
алгоритма планирования доставки, а другие хранят возвращаемые значения. Далее
также показано, какие из них используются другими функциями, реализованными
в монолите.
Рис. 13.18. Сущности и поля, которые участвуют в управлении доставкой и других функциях,
реализованных монолитом. Поле может быть доступно для чтения и/или записи. К нему может
обращаться механизм управления доставкой и/или монолит
Алгоритм планирования доставки считывает различные атрибуты, включая restau
rant, promisedDeliveryTime и deliveryAddress из Order и location и availability
из Courier, а также текущие планы. Он обновляет планы Courier и поля sche
duledPickupTime и scheduledDeliveryTime из Order. Как видите, поля, участвующие
в управлении доставкой, используются и в монолите.
Какие данные следует перенести в сервис Delivery
Определившись с тем, какие сущности и поля участвуют в управлении доставкой,
мы должны решить, какие из них должны попасть в сервис. Если бы данные, нужные
сервису, использовались только им самим, их извлечение было бы крайне прямоли
нейным. Но такие идеальные ситуации встречаются довольно редко, и этот случай
не исключение. Все сущности и поля, которые задействуются для управления до
ставкой, участвуют в других функциях, реализованных монолитом.
В итоге при определении того, какие данные нужно переместить в сервис, следует
учитывать два момента: каким образом сервис обращается к данным, остающимся
в монолите, и как монолит обращается к данным, вынесенным в сервис. К тому же,
как сказано в разделе 13.3, нужно тщательно продумать способ согласования данных
между сервисом и монолитом.
13.5. Разбиение монолита на части: извлечение управления доставкой 535
Основная функция сервиса Delivery заключается в управлении планами дей
ствий курьеров и обновлении полей scheduledPickupTime и scheduledDeliveryTime
сущности Order. Таким образом, имеет смысл перенести эти поля в сам сервис.
То же самое можно было бы сделать с полями Courier. location и Courier .availabi
lity. Но поскольку мы пытаемся минимизировать изменения, их лучше пока оста
вить в монолите.
Структура доменной логики сервиса Delivery
Структура доменной модели сервиса Delivery показана на рис. 13.19. В ее осно
ве лежат доменные классы Delivery и Courier. Класс DeliveryServicelmpl слу
жит точкой входа в бизнес-логику управления доставкой. Он реализует интер
фейсы Deliveryservice и Courierservice, к которым обращаются обработчики
DeliveryServiceEventsHandler и DeliveryServiceNotificationsHandlers, описанные
ранее в этом разделе.
Рис. 13.19. Структура доменной модели сервиса Delivery
536 Глава 13 • Процесс перехода на микросервисы
Бизнес-логика управления доставкой в основном состоит из кода, скопиро
ванного из монолита. Например, мы скопируем в сервис Delivery сущность Order
и переименуем ее в Delivery, а также удалим из нее все поля, кроме задействованных
в управлении доставкой. То же самое сделаем с сущностью Courier. Чтобы разра
ботать доменную логику для сервиса Delivery, придется отвязать код от монолита,
разрывая множество зависимостей. Это может занять много времени. И снова мы
приходим к тому, что рефакторинг кода намного проще происходит в статически
типизированных языках, поскольку в этом случае вам будет помогать компилятор.
Сервис Delivery несамостоятельный. Рассмотрим структуру интеграционного
слоя, который позволяет ему взаимодействовать с монолитом FTGO.
13.5.4. Структура интеграционного слоя
для сервиса Delivery
Чтобы управлять доставкой, монолиту FTGO необходимо обращаться к сервису
Delivery и обмениваться с ним данными. Это взаимодействие становится воз
можным благодаря интеграционному слою, структура которого представлена на
рис. 13.20. Сервис Delivery предоставляет API для управления доставкой и публи
кует доменные события Delivery и Courier. Монолит FTGO публикует доменные
события Courier.
Рассмотрим структуру всех составляющих интеграционного слоя, начиная с API
для управления доставкой, принадлежащего сервису Delivery.
Структура API сервиса Delivery
Сервис Delivery должен предоставлять API, который позволит монолиту плани
ровать, пересматривать и отменять доставку. Как вы уже видели на страницах этой
книги, предпочтительным подходом является обмен асинхронными сообщениями,
поскольку он поощряет слабую связанность и улучшает доступность. Сервис Delivery
может подписаться на доменные события Order, публикуемые монолитом. Это позво
лит создавать, пересматривать и отменять доставку в зависимости от типа события.
Преимущество этого решения заключается в том, что монолиту не нужно обращаться
к сервису Delivery напрямую. Но есть и недостаток: сервис должен знать, как каждый
тип события Order влияет на соответствующую сущность Delivery.
Более разумным подходом будет реализовать в сервисе Delivery API на основе
уведомлений, который позволит монолиту явно управлять доставкой — планировать,
пересматривать и отменять. Этот API будет состоять из канала и трех типов прохо
дящих через него сообщений: ScheduleDelivery, ReviseDelivery и CancelDelivery.
Например, уведомление ScheduleDelivery содержит время отгрузки, время до
ставки и адрес клиента. Важным преимуществом данного подхода является то, что
сервису Delivery не нужно знать подробностей о жизненном цикле сущности Order.
Он полностью сосредоточен на управлении доставкой и никак не связан с заказами.
Взаимодействие сервиса Delivery и монолита FTGO не ограничивается лишь
этим API. Также им нужно обмениваться данными.
13.5. Разбиение монолита на части: извлечение управления доставкой 537
< Z
>— 3
ф I
Ф X
Z ф
X Z
§
% .п и
ф Оч
а S
о X
X X
ш ф
CL
Ф 5
О о
£ ф -х
538 Глава 13 • Процесс перехода на микросервисы
Как сервис Delivery получает доступ к данным монолита FTGO
Сервису Delivery нужна информация о местоположении курьера и его доступности,
принадлежащая монолиту. Поскольку объем этих данные может быть большим,
сервису непрактично постоянно запрашивать их у монолита. Вместо этого моно
лит может реплицировать данные в сервис Delivery, публикуя доменные события
CourierLocationUpdated и CourierAvailabilityUpdated. У сервиса Delivery есть
объект CourierEventSubscriber, который подписывается на эти события и обновляет
свою версию сущности Courier. Он может также инициировать повторное плани
рование доставки.
Как монолит FTGO получает доступ к данным сервиса Delivery
Монолиту FTGO нужно считывать такие данные, как план действий курьера,
которые были перенесены в сервис Delivery. Теоретически он мог бы их просто
запрашивать, но это потребовало бы его существенной модификации. Пока что
проще оставить доменную модель и схему базы данных монолита без изменений
и реплицировать данные из сервиса обратно в монолит.
Чтобы этого добиться, проще всего публиковать из сервиса Delivery до
менные события для сущностей Courier и Delivery. Сервис публикует событие
CourierPlanUpdated при обновлении плана действий курьера и событие Deli-
veryScheduleUpdate при обновлении доставки. Все это потребляется монолитом,
который обновляет свою базу данных.
Мы рассмотрели, как монолит FTGO и сервис Delivery взаимодействуют между
собой. Теперь возьмемся за изменение монолита.
13.5.5. Изменение монолита для взаимодействия
с сервисом Delivery
Реализация сервиса Delivery — во многом самая простая часть процесса извлечения.
Изменение монолита FTGO будет намного более сложным. К счастью, репликация
данных из сервиса обратно в монолит сокращает объем этих изменений. Но нам все
равно нужно сделать так, чтобы монолит управлял доставкой через сервис Delivery.
Давайте посмотрим, как это сделать.
Определение интерфейса Deliveryservice
Прежде всего необходимо инкапсулировать код управления доставкой внутри Java-
интерфейса, который будет привязан к API на основе обмена сообщениями, опре
деленному ранее. Этот интерфейс (рис. 13.21) содержит методы для планирования,
перепланирования и отмены доставки.
Позже мы реализуем этот интерфейс с помощью прокси-класса, который шлет
сообщения сервису Delivery. Но сначала наша реализация будет вызывать код
управления доставкой.
13.5. Разбиение монолита на части: извлечение управления доставкой 539
Рис. 13.21. Первый шаг — определение обобщенного API Deliveryservice для удаленного
обращения к логике управления доставкой
Интерфейс Deliveryservice является обобщенным, поэтому для его реализации
хорошо подходит механизм IPC. Он содержит методы schedule(), reschedule()
и cancel(), которые относятся к разным типам сообщений, описанным ранее.
Рефакторинг монолита для вызова интерфейса Deliveryservice
Далее нужно определить в монолите FTGO все участки кода, которые занима
ются управлением доставкой, и сделать так, чтобы они использовали интерфейс
Deliveryservice (рис. 13.22). Это один из самых сложных аспектов извлечения
сервиса из монолита. Он может занять некоторое время.
Рис. 13.22. Вторым шагом будет модификация монолита FTGO для управления доставкой
через интерфейс Deliveryservice
540 Глава 13 • Процесс перехода на микросервисы
Процесс, безусловно, будет проще, если монолит написан на статически типизи
рованном языке, таком как Java, который предоставляет лучшие инструменты для
определения зависимостей. В противном случае желательно иметь автоматические
тесты с достаточным охватом участков кода, которые нужно изменить.
Реализация интерфейса Deliveryservice
Завершающим шагом будет замена класса DeliveryServicelmpl прокси-классом,
который шлет уведомления отдельному сервису Delivery. Но вместо того, чтобы
сразу отказываться от существующей реализации, мы воспользуемся схемой, по
казанной на рис. 13.23, которая позволяет монолиту динамически переключаться
между классом DeliveryServicelmpl и сервисом Delivery. Для этого мы реализуем
интерфейс Deliveryservice с помощью класса, который будет применять динами
ческое переключение возможностей.
Рис. 13.23. Завершающий шаг — реализация Deliveryservice с помощью прокси-класса, который
шлет сообщения сервису Delivery. Переключатель возможностей определяет, что должен вызывать
монолит FTGO — старую реализацию или новый сервис Delivery
Использование переключателя возможностей значительно снижает риск отката
сервиса Delivery. Сначала сервис развертывается и тестируется. Убедившись в том,
что он работает, мы можем переключить на него входящий трафик. Если обнаружит
ся, что сервис Delivery ведет себя не так, как ожидалось, можно вернуться к старой
реализации.
Резюме 541
Убедившись в том, что сервис Delivery работает как следует, можно убрать из
монолита код для управления доставкой.
Delivery и Delayed Order — это примеры сервисов, которые команда FTGO раз
работает в процессе перехода на микросервисную архитектуру. Последующие шаги
будут зависеть от бизнес-приоритетов. Один из вероятных вариантов — извлечение
сервиса Order History, описанного в главе 7. Это частично устранит необходимость
в репликации данных из сервиса Delivery обратно в монолит.
После реализации Order History команда FTGO может извлекать сервисы в по
рядке, описанном в подразделе 13.3.2: Order, Consumer, Kitchen и т. д. В ходе этого
процесса приложение будет становиться все более простым в обслуживании и те
стировании, а темп разработки станет увеличиваться.
Резюме
□ Прежде чем мигрировать на микросервисы, следует убедиться в том, что про
блемы с доставкой Г1О вызваны недостатками монолитной архитектуры. Иногда
доставку можно ускорить за счет внесения корректив в процесс разработки.
□ Переходить на микросервисы следует постепенно, разрабатывая удушающее
приложение. Оно состоит из микросервисов, которые вы создаете вокруг суще
ствующего монолита. Вы должны как можно раньше и чаще демонстрировать
преимущества перехода, чтобы заручиться поддержкой руководства.
□ Отличный способ внедрения микросервисов в вашу архитектуру — реализация
новых возможностей в виде сервисов. Это позволяет быстро и просто добавлять
новые функции с помощью современных технологий и эффективного процесса
разработки. Вы сможете быстро продемонстрировать выгоду от миграции на
микросервисы.
□ Чтобы разбить монолит, можно отделить уровень представления от внутренних
компонентов, в результате чего получатся два монолита поменьше. Это не очень
существенное улучшение, но оно позволит развертывать каждый монолит по
отдельности. Благодаря этому, например, отдельной команде будет проще разви
вать пользовательский интерфейс, не затрагивая внутреннюю часть приложения.
□ Основной способ разбиения монолита заключается в постепенном переносе
его функций в сервисы. В первую очередь стоит сосредоточиться на наиболее
542 Глава 13 • Процесс перехода на микросервисы
полезных возможностях. Например, вы сможете ускорить процесс разработки,
если реализуете в виде сервиса активно развивающийся код.
□ Новым сервисам почти всегда приходится взаимодействовать с монолитом.
Например, сервису нужно часто обращаться к данным монолита и его функциям.
Монолиту тоже иногда необходим доступ к данным и возможностям сервиса.
Чтобы организовать такое взаимодействие, следует создать интеграционный
слой, который состоит из входящих и исходящих адаптеров, размещенных
в монолите.
□ Чтобы доменная модель монолита не засоряла доменную модель сервиса, инте
грационный код должен использовать предохранительный слой, который про
изводит преобразования между этими доменными моделями.
□ Чтобы извлечение сервисов как можно меньше влияло на монолит, в его БД можно
реплицировать данные, вынесенные в сервис. Схема монолита остается неизмен
ной, поэтому не нужно вносить существенные правки в его кодовую базу.
□ Разработка сервиса часто требует выполнения повествования, в котором уча
ствует монолит. Однако реализация компенсируемых транзакций, требующих
внесения масштабных изменений в данные кода монолита, может вызвать
определенные трудности. Поэтому иногда следует тщательно планировать
порядок извлечения сервисов, чтобы компенсируемые транзакции не попали
в монолит.
□ При переходе на микросервисную архитектуру нужно одновременно поддержи
вать два механизма безопасности: тот, что уже реализован в монолитном при
ложении и обычно основан на сеансе, хранящемся в памяти, и систему токенов,
использованную в сервисах. К счастью, мы можем просто модифицировать обра
ботчик входа в систему так, чтобы он генерировал cookie с токеном безопасности,
затем API-шлюз будет передавать этот токен сервисам.
Крис Ричардсон
Микросервисы. Паттерны разработки и рефакторинга
Перевел с английского С. Черников
Заведующая редакцией
Руководитель проекта
Ведущий редактор
Литературный редактор
Художественный редактор
Корректор
Верстка
Ю, Сергиенко
С. Давид
Н, Гринчик
И. Рощина
В. Мостипан
Е. Павлович
Г. Блинов
Изготовлено в России Изготовитель ООО «Прогресс книга»
Место нахождения и фактический адрес 194044, Россия, г. Санкт-Петербург,
Б Сампсониевский пр, д 29А, пом. 52 Тел.: 178127037373.
Дата изготовления 07 2019 Наименование книжная продукция. Срок годности: не ограничен.
Налоговая льгота — общероссийский классификатор продукции ОК 034-2014, 58.11.12 — Книги печатные
профессиональные, технические и научные.
Импортер в Беларусь ООО «ПИТЕР М», 220020, РБ, г. Минск, ул Тимирязева, д. 121/3, к. 214, тел./факс: 208 80 01
Подписано в печать 21 06.19 Формат 70х 100/16 Бумага офсетная. Усл. п. л. 43,860. Тираж 1000 Заказ 5232
Отпечатано в АО «Первая Образцовая типография» Филиал «Чеховский Печатный Двор»
142300, Московская область, г. Чехов, ул. Полиграфистов, 1
Сайт wwwchpd ru, E-mail sales@chpd.ru
тел 8(499)270-73-59 ~
ИЗДАТЕЛЬСКИЙ ДОМ
ВАША УНИКАЛЬНАЯ КНИГА
Хотите издать свою книгу? Она станет идеальным подарком для партнеров
и друзей, отличным инструментом для продвижения вашего бренда, презентом
для памятных событий! Мы сможем осуществить ваши любые, даже самые
смелые и сложные, идеи и проекты.
МЫ ПРЕДЛАГАЕМ:
• издать вашу книгу
• издание книги для использования в маркетинговых активностях
• книги как корпоративные подарки
• рекламу в книгах
• издание корпоративной библиотеки
Почему надо выбрать именно нас:
Издательству «Питер» более 20 лет. Наш опыт - гарантия высокого качества.
Мы предлагаем:
• услуги по обработке и доработке вашего текста
• современный дизайн от профессионалов
• высокий уровень полиграфического исполнения
• продажу вашей книги во всех книжных магазинах страны
Обеспечим продвижение вашей книги:
• рекламой в профильных СМИ и местах продаж
• рецензиями в ведущих книжных изданиях
■ интернет-поддержкой рекламной кампании
Мы имеем собственную сеть дистрибуции по всей России, а также на Украине
и в Беларуси. Сотрудничаем с крупнейшими книжными магазинами.
Издательство «Питер» является постоянным участником многих конференций
и семинаров, которые предоставляют широкую возможность реализации книг.
Мы обязательно проследим, чтобы ваша книга постоянно имелась в наличии
в магазинах и была выложена на самых видных местах.
Обеспечим индивидуальный подход к каждому клиенту, эксклюзивный дизайн,
любой тираж.
Кроме того, предлагаем вам выпустить электронную книгу. Мы разместим
ее в крупнейших интернет-магазинах. Книга будет сверстана в формате ePub
или PDF - самых популярных и надежных форматах на сегодняшний день.
Свяжитесь с нами прямо сейчас:
Санкт-Петербург - Анна Титова, (812) 703-73-73, titova@piter.com
Москва - Сергей Клебанов, (495) 234-38-15, klebanov@piter.com